Compare commits

..
94 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 660e15c9f4 Release 0.15.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:46:11 -04:00
Brandon ThomasandClaude Opus 5.5 d4eab70e63 DWG bridge: build without acadrust on FreeBSD aarch64, where 0.6.6 doesn't compile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:46:09 -04:00
Brandon ThomasandClaude Opus 5.5 822f61b73a Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:22:50 -04:00
Tanish Manku dc1442ac89 Merge pull request #2525 from storytold/land/2467
fix(io): MTEXT keeps its R2018 embedded column object (#2248)
2026-10-30 19:25:50 -07:00
Tanish Manku 737f8a2ec6 Merge remote-tracking branch 'origin/main' into pr-2467 2026-10-30 19:25:46 -07:00
Tanish Manku b22a068262 Merge pull request #2524 from storytold/land/2201
Update symlink target in write_atomic instead of replacing the link
2026-10-30 19:25:40 -07:00
Tanish Manku d5128dfcd1 Merge remote-tracking branch 'origin/main' into pr-2201 2026-10-30 19:25:36 -07:00
Tanish Manku 60f1bd6277 Merge pull request #2523 from storytold/land/1693
DXF: save pre-2007 text in the file's code page instead of all \U+ escapes
2026-10-30 19:25:30 -07:00
Tanish Manku c808939bd1 Merge remote-tracking branch 'origin/main' into pr-1693 2026-10-30 19:25:26 -07:00
Tanish Manku 344c022071 Merge pull request #2522 from storytold/land/2460
fix(fonts): place Middle and bottom-justified text as AutoCAD does (#2229)
2026-10-30 19:24:52 -07:00
Tanish Manku 5e52e5285a Merge remote-tracking branch 'origin/main' into pr-2460 2026-10-30 19:24:37 -07:00
Jonasandcloudj 5bc93ccf18 test(io): add independent DXF/DWG conformance and preservation matrices (#2237)
* test(dxf): add deterministic codec and drawing round-trip matrices

Exercise typed tags and model-space LINE/CIRCLE/TEXT through in-memory ASCII and binary DXF at every save version. Compare both reads with the original model and check independent handwritten byte/entity fixtures. Keep desired-behaviour tests for extrusion and ByBlock plot-style loss explicitly ignored until fixed. No production or docs changes.

* test(dxf): independently verify common entity properties

* test(io): add a broad DWG preservation matrix with focused pending regressions

Share deterministic Drawing cases and exact assertions between DXF and DWG without reducing DXF coverage. Run representable DWG cases at all six save versions, twice in RAM. Test R2000 color/transparency and the single TEXT elevation as explicit format limits. Keep six independently reproduced loss regressions ignored without weakening comparisons. No production or docs changes.

* test(io): close binary-width and referenced-linetype coverage gaps

Check every integer group range against independently constructed binary payloads, catching shared reader/writer width mistakes. Resolve linetypes with Drawing lookup and compare layer linetypes too. Add missing/changed-reference self-tests and inherited-pattern DXF/DWG round trips. Reproduced the review counterexamples before and after; full CI passes with two nextest threads. Test-only changes.

* test(io): expand DXF and DWG matrices across entities and drawing collections

Add basic coverage for all 29 modeled entity kinds, block graphs and attributes, all ACI colors, richer properties/XDATA, sparse handles, headers, symbol tables, views, groups, layouts and foreign payloads. Preserve typed fields and authored references through two RAM saves. Add independent DXF groups and native DWG bridge checks, plus explicit refusal-or-preservation tests for unknown entities. Keep confirmed discrepancies and unresolved preservation contracts as focused ignored tests; native linetype angles and silently dropped unknown DWG entities are independently reproduced. Version limits and foreign object handle remapping are explicit contracts, not blanket normalization. Tests only; no production or docs changes. Full CI passes with two nextest threads: 2777 passed, 27 skipped.

* test(io): cover bounded property domains and targeted field interactions

Independently check all ACI values and transparency percentages, native
layer/style flag combinations, and literal large handles. Add shared DXF/DWG
cases for alignment with mirrored styles, inherited versus explicit zero
properties, repeated nested attributes, and integer-boundary handles.

Keep exhausted handle allocation separate: DWG refuses the save; a focused
ignored DXF regression reproduces silent loss of all nine model entities.
No production or documentation changes.

Validation: full cargo xtask ci with NEXTEST_TEST_THREADS=2 passes all seven
gates (2792 passed, 28 skipped). The pending DXF regression fails explicitly
when run with --ignored.

* test(dxf): separate chunk conformance from binary reader tolerance

Address PR #2237 review: shared exact-tag round trips only use hex chunks
within the documented 127-byte group limit. Independently inspect ASCII
and binary writer bytes, reassemble payloads, and verify following-record
framing. Keep full 0..255 length-byte read tolerance in handwritten fixtures,
not as a claim of conforming writer output.

Add two focused ignored regressions for oversized ASCII/binary output,
reproduced with 128-byte payloads. They extend existing issue #1025.
No production changes.

Validation: cargo xtask ci passes all seven gates with nextest 0.9.150
and two test threads (2782 passed, 30 skipped). Both new pending checks
fail explicitly when run with --ignored.

* test(io): compare DWG dimension blocks through their references

Anonymous dimension-block suffixes may change between drawing sessions (Autodesk documentation; ODA DWG section 20.4.52.1). Resolve their names through preserved dimension identities and keep strict checks for cache content, entity handles, block properties and reference integrity.

Enable the pending dimensions test across all six DWG versions and two saves. Add a paired-cache fixture and comparator controls rejecting swapped/merged/dangling references, geometry and property changes, stale caches, and named-block renaming. Leave the shared comparator and DXF contract unchanged.

Validation: cargo xtask ci (all seven stages; 2786 passed, 29 skipped); roundtrip matrix (211 passed, 26 ignored).

Resolves the preservation-contract decision in #2257 (tracker #2238, B4); no production codec fix.

* test(io): specify WIPEOUT explicit closure on roundtrip

An implicit WIPEOUT boundary gains one explicit closing marker in DXF/DWG. Compare against independently authored explicit boundaries in both winding directions across all six versions and two saves; retain strict field and resave comparisons. Enable the two pending closure checks without changing the shared comparator.

Distinguish this valid file representation from the grip-edit defect: the latter is fixed separately in PR #2284, with engine, outline and rendering regressions. No production change in this test-only branch.

Validation: cargo xtask ci, all seven stages passed (2788 passed, 27 skipped); active roundtrip matrix 213 passed; remaining ignored checks reproduce 24 drawing failures and two codec failures. One unrelated HTTP control-server test failed transiently, then passed in isolation and in the full rerun.

Related #2256 and tracker #2238 B3.

* test(io): expose multiline ATTRIB alignment corruption

Keep the original origin witness and add distinct nonzero TEXT/MTEXT anchors. An active R2018 ASCII/binary two-save check verifies world-space multiline geometry and value edits; this does not excuse corrupted fallback TEXT fields.

Retain the strict pending #2255 check and add comparison of independently downgraded R2000/R2004 geometry. The observed Left -> Center change is a real reader error, not permitted alignment-point canonicalization. The reader hunk from existing PR #1942 makes the extended strict check pass in an isolated experiment; no competing production fix is included here.

Validation of this test-only tree: all seven cargo xtask ci stages pass (2789 passed, 27 skipped); active matrix 214 passed; remaining 24 drawing and two codec diagnostics still fail. An unrelated HTTP control-server test failed transiently, then passed alone and in the complete rerun.

Related #2255, #1942 and tracker #2238 B2.

* test(io): separate ATTRIB value and legacy-placement guarantees

Assert exact saved ATTRIB values, read_as and authored embedded MTEXT contents before and after value edits. A mutation test rejects corrupt fields, including a value/read_as pair whose rendered geometry remains unchanged.

Make legacy placement an independently runnable pending regression instead of a callback behind the failing field comparator. Compare direct R2000/R2004 exports with exports after one/two R2018 roundtrips, both encodings in each stage, and verify that the primary single-line fallback actually survives. Keep all three contracts local and distinct; remove the old cross-module callback.

The new legacy test fails directly on world-space geometry with the unchanged reader. Both pending ATTRIB tests pass with the reader hunk from existing PR #1942 in an isolated experiment (548 IO tests also pass). No production fix is included.

Validation: all seven cargo xtask ci stages pass with NEXTEST_TEST_THREADS=1 (2790 passed, 28 skipped). Earlier parallel runs and one isolated retry hit pre-existing HTTP/busy control-server failures; no exclusions or test changes. Active matrix 215 passed; all 25 drawing and two codec pending diagnostics still fail.

Addresses review of #2237; related #2255, #1942 and tracker #2238 B2.

---------

Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-30 19:24:31 -07:00
Tanish Manku 9c0c02f917 Merge main into #2467 2026-10-30 19:20:06 -07:00
Tanish Manku d79f4f5732 Merge main into #2201 2026-10-30 19:20:04 -07:00
Tanish Manku 8822462be2 Merge main into #1693 2026-10-30 19:19:59 -07:00
Tanish Manku e348a30ebd Merge main into #2460 2026-10-30 19:19:54 -07:00
Tanish Manku b187af646a Save helpers used only by the file system build stay out of the wasm one (#2521) 2026-10-30 19:19:06 -07:00
ElarionusandEthan Dale 75efe07d85 Enter on a partly typed command runs the highlighted suggestion (#2135)
Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
2026-10-30 19:16:59 -07:00
assamaralandClaude Fable 5.1 4792ca2941 fix(io): a centred ATTDEF keeps its insertion point in a DWG (#2464) (#2466)
* test(io): a centred ATTDEF keeps its insertion point in a DWG (#2464)

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

* fix(io): an ATTDEF with no default value is placed by its tag's width in DWG (#2464)

The writer keeps a justified text's stored insertion point only when it is near where the
baseline starts for the text's width; an ATTDEF with an empty default value has width 0, so every
AutoCAD insertion point further than half a height from the alignment point was replaced by the
alignment point. The tag is what is drawn: judge against its width.

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

* fix(io): keep a stored ATTDEF point that is close to the empty text's start

The tag-width baseline check replaced a point read from a drawing whenever the
font's width of the tag put the computed start further than the allowance away,
e.g. a centred LONGTAG with group 10 at x=99.5 and group 11 at x=100 moved to
about 92.17 in DXF and DWG. A stored point close to the empty text's own start
is now kept as before; only otherwise is it judged against the tag.

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

* test(io): store an ATTDEF point that differs from the computed baseline

`assert_kept(start)` stored exactly the baseline the tag-width fallback computes, so a write that replaced group 10 with that baseline passed too. The point is now 1 off the start, within the tolerance. Review of #2466.

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

* test(io): an empty-value ATTDEF keeps its point with an empty or absent prompt too

The tag-width check applies to every ATTDEF with no default value, since the
CAD displays the tag whatever the prompt: the guard now says `attdef` instead
of reading as a prompt test, and the test covers a prompt, an empty one and none.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 19:16:55 -07:00
assamaralandClaude Sonnet 5.5 b4b8ae294a fix(io): write a LINE's extrusion (210/220/230) (#2241) (#2417)
* test(io): a LINE keeps its extrusion through DXF and DWG (#2241)

`line_extrusion_roundtrips_with_world_endpoints` (red: group 230 is not written for a LINE with
normal (0, 0, -1), so it comes back with (0, 0, 1)).

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

* fix(io): write a LINE's extrusion (210/220/230) (#2241)

A LINE's points are world coordinates and the reader already keeps its extrusion (the direction
of its thickness), but the writer left the groups out, so a normal (0, 0, -1) came back as
(0, 0, 1) from DXF and from DWG, which is written through DXF.

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

* test(io): a LINE's extrusion is written as a unit vector or not at all (#2241)

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

* fix(io): write a LINE's extrusion normalized, nothing for a zero or NaN normal (#2241)

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

* fix(io): write a tiny extrusion's direction, drop a non-finite one

Vec3::normalized returns zero at or below the drawing tolerance, so a LINE with
extrusion (0, 0, -1e-10) saved with no 210/220/230 and reopened as +Z, flipping a
thickness; an infinite component normalized to NaN and was written as a zero
vector. The writer now normalizes by the vector's own length and writes nothing
only for a zero or non-finite vector.

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

* fix(io): keep the direction of a huge finite extrusion

The length of (0, -1.5e308, 1.5e308) overflows to infinity, so the normal came out as (0, 0, 0) and was written as such. The vector is scaled by its largest component before it is normalized; the case is in `line_extrusion_is_written_as_a_unit_vector_or_not_at_all`. Review of #2417.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 19:16:51 -07:00
assamaralandClaude Sonnet 5.5 85fc3991b1 fix(io): an attached ATTRIB keeps its Constant mode through DXF and DWG (#2246) (#2418)
* test(io): the Constant mode of an attached ATTRIB survives DXF and DWG (#2246)

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

* fix(io): an attached ATTRIB keeps its Constant mode through DXF and DWG (#2246)

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

* test(io): attached ATTRIB Constant mode over every save version and encoding

The regression covered the default DXF and DWG saves only. It now also saves
with each Version::ALL entry as ASCII DXF, binary DXF and DWG.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 19:16:47 -07:00
assamaralandClaude Fable 5.1 9b7149676b fix(io): MTEXT legacy column groups no longer leak into the entity (#2247) (#2416)
* test(io): MTEXT legacy column groups stay in the AcDbMText subclass (#2247)

`legacy_column_groups_stay_in_the_mtext_subclass` (red: the column width 48 is read as the
entity's ltscale, 12.5; the height count 50 as a column height).

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

* fix(io): MTEXT legacy column groups no longer leak into the entity (#2247)

`common()` read the linetype scale (48) and the other AcDbEntity groups from the whole record,
so the column width 48 of an MTEXT's legacy column groups became its ltscale. They are now read
from the AcDbEntity subclass only. `read_columns` took the height count (the first 50) for a
column height and `column_tags` did not write it; both follow the DXF layout now.

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

* test(engine): the MTEXT entmake column heights carry their count (#2247)

Group 50 after the column type is the height count, then the heights. Thanks to austin62jake for the report and the fix.

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

* test(io): a marker-less MTEXT with an embedded object keeps its own ltscale (#2247)

Case from #2276 (Zaxaerith): without subclass markers, the 48 of an embedded object (101) was read as Common.ltscale.

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

* fix(io): stop the entity's own groups at an embedded object (#2247)

Without subclass markers, an embedded object's 48 was read as Common.ltscale. Suggested by austin62jake; case and test from #2276 (Zaxaerith).

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

* style(io): rustfmt the new MTEXT test

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

* fix(io): keep layer and colour that follow a subclass marker in a list with no AcDbEntity marker

entity_groups ended the common region at the first subclass marker other than
AcDbEntity, so an entmake list such as (0 . "LINE") (100 . "AcDbLine") (8 . "Walls")
was read on layer 0. The region now ends at a marker only when the record has an
AcDbEntity marker (files from R13 on always do); other records are read as a
whole up to an embedded object (101), as before the column change.

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

* fix(io): markers inside an application-defined group do not end the entity's groups

A `102 {...102 }` group before AcDbEntity may hold any code, 100 and 101 included: `entity_groups` stopped there and the layer and colour that followed fell back to defaults. The scan now skips the contents of such groups when looking for the AcDbEntity marker and for the end of its groups. Review of #2416.

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

* fix(io): keep every column height of files written before the height count

CADCraft up to f536bf4 wrote group 76 = n and n group-50 heights, no count.
Reading those with the counted format dropped the first height and rewrote
76 as n - 1. The first 50 is a count only when there are 76 + 1 of them.

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

* fix(io): MTEXT column heights skip application-defined groups; no count for an empty list

A `102 {...}` group inside the MTEXT record may hold a 50 of its own: it
counted as a height and made a legacy record (76 = n, n heights, no count)
look counted, dropping the first height. `read_columns` now filters the
record with `outside_app` (shared with `entity_groups`).

`column_tags` wrote a lone `50 = 0` for a dynamic manual column with no
heights, which read back as one height of 0; the count is omitted for an
empty list, so `heights: []` round trips. ZWCAD 2025 also read that lone
50 as the rotation and zeroed it.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 19:16:42 -07:00
midasmatterhorn dc6a0e9ec3 engine: renaming a layer repoints every viewport's frozen list and colour overrides (#1264) 2026-10-30 19:16:38 -07:00
2701983688 Ctrl/Cmd+W closes the drawing, as the File menu shows (#2057)
* Ctrl/Cmd+W closes the drawing, as the File menu shows

File > Close shows Cmd+W (Ctrl+W on Windows and Linux), but the keyboard
table had no entry for it, so the key did nothing outside the macOS native
menu. The table moves into builtin_shortcuts() (the shape #1703 also uses),
and a test checks that every shortcut a command shows runs that command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG

* Clippy: assign operators in the shortcut test's key parser

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG

---------

Co-authored-by: ATARYS-LAB <342661122+ATARYS-LAB@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:16:34 -07:00
1ecc2a130b DXF: no CLASS instance counts in R2000 files (AutoCAD LT 2026 hung opening them) (#1060)
* DXF: no CLASS instance counts in R2000 files (AutoCAD LT 2026 hung on them)

Since #635 every CLASS record carries group 91, the instance count, at every
version. The count came with the 2004 format: R2000 CLASS records are 1, 2, 3,
90, 280, 281. AutoCAD LT 2026 stops responding when it opens an R2000 DXF
whose CLASS records have a 91, so every R2000 file CADCraft saved locked it up
(a drawing opened from an R2000 file is saved as R2000 again).

Isolated black-box in AutoCAD LT 2026 with synthetic files: the same R2000
file without the 91 groups opens, as does the same content declared AC1032.

The counts are now written only when the file (or the DWG the bridge writes)
is 2004 or later; R2000 output drops the group from every CLASS record,
classes kept from a newer file included.

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

* Test: binary R2000 drops group 91 from a kept foreign CLASS too

Review follow-up: the kept-class check covered R2000 only through ASCII
saves; it now also saves the fixture as binary R2000 and checks that the
ACME_OBJECT CLASS record is 1, 2, 3, 90, 280, 281.

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

* Test: binary 2018 keeps a kept foreign CLASS's instance count

Review follow-up: the kept-class check now saves the fixture as binary at
both R2000 (no group 91) and 2018 (group 91 after the proxy flags, with the
class's count), through one helper.

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

---------

Co-authored-by: Slopapalooza <307952167+Slopapalooza@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:16:30 -07:00
0ab812c12a fix(io): write $PSTYLEMODE as read, in DXF and DWG (#2400)
* test(io): `$PSTYLEMODE` round-trips through DXF and DWG (#2332)

Red test: a drawing read from a DXF with `$PSTYLEMODE` 0 (named plot
styles) is written back without the variable, so readers default to
colour-dependent plot styles.

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

* fix(io): write `$PSTYLEMODE` as read, in DXF and DWG (#2332)

`HEADER_VARS` did not list `$PSTYLEMODE`, so a drawing read from a
named-plot-style file (0) was saved without it; the DWG bridge then
defaulted to colour-dependent plot styles (1) and the plot came out
with thicker edges. One more entry in the list; a drawing that never
had the variable still writes nothing.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: assamaral <217119886+assamaral@users.noreply.github.com>
2026-10-30 19:16:25 -07:00
Tanish Manku 47ed3af26c Parity docs are updated by maintainers in batches, not in each PR (#2520)
docs/gaps.md conflicts in 153 open PRs and docs/ui-parity.md in 75, because every PR had to edit them. A PR now names its rows in the description.
2026-10-30 19:11:01 -07:00
Tanish MankuandAldo Zarate 3d623fc4d4 Saving over a DWG asks before it loses content, and saves keep a .bak (#2518)
Includes the .bak work from #1003.

Co-authored-by: Aldo Zarate <azarate@azf.com.mx>
2026-10-30 19:10:57 -07:00
Tanish Manku 04e74a165c Panic hunt covers every command, and a panic after a command restores the drawing (#2517)
panic_hunt runs every command in a fresh session and fails on real panics. The guard now covers the steps after a command, so a late panic restores the drawing instead of ending the app.
2026-10-30 19:10:52 -07:00
Dylan M. Taylor ba3196e3f7 fix(flatpak): allow reading launcher version pins (#2515)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:28 -04:00
assamaralandClaude Fable 5.1 8038de04e4 fix(io): an MTEXT with columns takes its width from the column groups
ZWCAD and AutoCAD write the total width of the columns in the entity's group 41 and the column width in the embedded object's 44 (48 of the legacy groups). `mtext_from` took 41 whenever it was positive, so a three-column MTEXT of 50 came back 175 wide and the R2018 object was written with 44 = 175, 42 = 550. With columns and a positive column width, that width is the MTEXT's (it is one column's throughout). Review of #2467.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 17:35:29 -03:00
Zinadin ZidanandClaude Opus 5.5 8d1bd19ee2 Save As suggests the default save format for opened drawings too (#2199) (#2509)
* Save As suggests the default save format for opened drawings too (#2199)

Options > Open and Save > "Save as" in AutoCAD is the format every drawing
is saved in. CADCraft applied File > Default Save Format only to untitled
drawings, so Save As on an opened DWG kept DWG with DXF chosen, and the
other way round. Save As now swaps a DWG, DXF or DWT extension for the
chosen format; other names, and Save of an opened file, are unchanged.

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

* Default Save Format's echo says Save As will suggest the format (#2199)

"Save As will save drawings as DWG" promised more than Save As does: it
only swaps a DWG, DXF or DWT extension in the name it suggests, and the
path picked in the dialog decides the format. The echo now reads
"Save As will suggest DWG", and default_save_format_applies_to_opened_drawings
pins it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:34 -04:00
43c41b8e68 bs: Add Bosnian (bs) to the interface languages (#2502)
* Add Bosnian (bs) to the interface languages

bs.tsv: Bosnian (Latin script, Ijekavian) catalog covering every key of
CADCraft's other catalogs, its wording shared with storytold/craft-i18n
locales/bs/ and the strings craft-i18n lacks translated new. Every bs-*
locale selects it; counts use one/few/other. Locale and plural tests,
localization docs and an attribution entry (AI-assisted, not yet
reviewed by a native speaker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tayw8nmxoLLmKv13jK93z

* docs(localization-parity): Bosnian in the Languages table (CodeRabbit)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 15:46:30 -04:00
18d3aa4ed8 CHPROP: change properties at the command line, as AutoCAD asks for them (#2497)
* Add CHPROP, asking for properties as AutoCAD does

CHPROP selects objects, then asks "Enter property to change
[Color/LAyer/LType/ltScale/LWeight/Thickness/TRansparency]" until Enter,
applying each value through properties.set. Measured from AutoCAD 2024
through AutoLISP's command: colours 1-255, names, BYLAYER, BYBLOCK or
Truecolor; 0, 256 and 300 refused; a layer must exist; a linetype not
loaded is loaded from the standard file; a lineweight goes to the nearest
standard one with a note.

Keyword prefixes now have to reach past the keyword's last capital, so
"L" alone matches none of LAyer, LType, ltScale and LWeight, as in
AutoCAD.

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

* CHPROP tests: transparency, and refused true colours and transparencies

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

* CHPROP leaves objects on locked layers out, as AutoCAD does

Each pick and the pickfirst selection lose the objects on locked layers,
followed by "1 was on a locked layer." or "N were on a locked layer.";
with none of the pickfirst selection left, CHPROP asks for objects. The
JSON chprop leaves them out too and returns {changed, locked}.

The keywords test also pins QDIM's DA and PEDIT's vertex E, which AutoCAD
2024 refuses although only one keyword starts with them.

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

* chprop: look up locked handles in a set, and always report "locked" (CodeRabbit)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 15:46:27 -04:00
MarekSpodBiedry 189bfcbf08 Draw annotative text with Match orientation to layout upright, ignoring its stored rotation (#2498) (#2504) 2026-10-30 15:46:24 -04:00
Books 8252d0e9bd Extents: an MTEXT's wrap width only narrows its box (#2465) (#2491)
* Extents: an MTEXT's wrap width only narrows its box (#2465)

A garbage group 41 width (3e14) became the box width, so the drawing extents were 3e14
wide. The width is now capped at the longest line's estimate. The width 0 branch is
unchanged. Tests: a_huge_mtext_wrap_width_does_not_inflate_the_extents and
a_narrow_mtext_wrap_width_still_sets_the_box_width.

* Parity checklist: regenerated after merging main

* Parity checklist: regenerated after merging main
2026-10-30 15:46:20 -04:00
2ez4gcxandClaude Opus 5.5 29584fa4c4 SOLID: draw 2D solids, chained as AutoCAD chains them (#2489)
* SOLID: draw 2D solids, chained as AutoCAD chains them

CADCraft had the SOLID entity but no command to draw it. As AutoCAD
2024 asks from AutoLISP: first, second, third point, then "fourth
point or <exit>" (Enter makes a triangle); further solids take the
previous one's last two corners and ask their third and fourth points
until Enter. Corners stay in the order given.

The command (alias SO) has a JSON form taking the points chained the
same way. solid_chains_its_corners_as_autocad pins seven measured
calls to 1e-9.

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

* SOLID: offer it in the Draw menu

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:16 -04:00
2ez4gcxandClaude Opus 5.5 6350c5b145 Extents: count layers that are turned off, as AutoCAD does (#2485)
* Extents: count layers that are turned off, as AutoCAD does

Drawing::extents (ZOOM Extents, EXTMIN/EXTMAX, $EXTMIN/$EXTMAX, Extents
plots) left out layers turned off as well as frozen ones. AutoCAD 2024
leaves out only frozen layers: lines from the origin to (10, 10) on
layer 0, (100, 0) on a layer turned off and (0, 200) on a frozen one
give EXTMAX (100.03, 10.01); inside a block too, and a reference on a
frozen layer counts for nothing.

Drawing::in_extents keeps a layer turned off, and extent_bounds counts
block contents on one. entity_bounds, which the spatial index and
selection use, still measures only what is drawn.

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

* Extents: leave out attributes on frozen layers

An insert's attribute lies on its own layer, else on its definition's,
as the renderer draws it. One on a frozen layer no longer stretches the
extents; one on a layer turned off still counts, as other objects do.

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

* Extents test: build the attribute without an unreachable arm

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:12 -04:00
2ez4gcxandClaude Opus 5.5 50df1284a3 -HATCH: Properties asks the pattern's scale and angle, and takes Solid (#2481)
* -HATCH: Properties asks the pattern's scale and angle, and takes Solid

The pattern option asked only for a name, so a script answering it as
AutoCAD asks (`_P ANSI31 2 45`) got "Unknown pattern 2" and "Unknown
pattern 45" and a hatch at scale 1, angle 0, and `_P _S` reported
"Unknown pattern _S".

As AutoCAD 2024's -HATCH Properties does, the pattern name is now
followed by its scale and then its angle (Enter keeps them), Solid
(S, _S or SOLID) sets a solid fill and asks neither, and an unknown
name or a refused value is asked again. The sCale and Angle options
and the pattern picker are unchanged.

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

* -HATCH: ask for the pattern again when a .pat file loads nothing

A .pat file that cannot be read, or holds no pattern, now repeats the
pattern prompt instead of going on to Scale and Angle with the
previous pattern.

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

* -HATCH: a pick at a Properties prompt asks again

Enter keeps the current pattern, scale or angle; a point or an object
(an entity name from LISP) answers none of those prompts, so the prompt
comes again instead of going on with the value unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:08 -04:00
2ez4gcxandClaude Opus 5.5 16ae23424d -INSERT: X, Y and Z presets, XYZ, and the Z factor AutoCAD stores (#2480)
* -INSERT: X, Y and Z presets, XYZ, and the Z factor AutoCAD stores

The insertion point prompt offers X, Y and Z: the factor for that axis
alone, the others 1, and no scale prompts after the point. The X scale
prompt offers XYZ, which asks X, Y and Z in turn. A Z factor not asked
is the size of the X factor: X -1 with Y as X stored Z -1, where AutoCAD
stores +1. Basepoint picks on the block dropped at the cursor, as
AutoCAD drops it where it is being dragged (the origin in a script),
instead of at its definition position.

Each was measured in AutoCAD 2024 from AutoLISP's command and is pinned
in insert_options_match_autocad.

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

* -INSERT: store the Z factor as AutoCAD does after Scale, Z and XYZ

Measured in AutoCAD 2024: Scale -2 gives X and Y -2 and Z 2; a Z preset
of -3 gives Z 3; XYZ with X -2 and Enter at Y and Z gives Z 2; a Z typed
at XYZ's Z prompt (-4) stays as typed. XYZ followed by a corner, with
Corner or a point straight away, still asks for Z, which defaults to
the size of the corner's X factor.

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

* -INSERT: an axis preset after Scale changes that axis alone

Measured in AutoCAD 2024: Scale -3 then X 2 gives 2/-3/3; X 2 then
Scale -3 gives -3/-3/3; Scale 2 then Z -5 gives 2/2/5. Scale now sets
all three presets, so a later X, Y or Z keeps the other two.

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

* -INSERT test: Basepoint with the cursor away from the origin

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:05 -04:00
2ez4gcxandClaude Opus 5.5 dda6562a97 Pick the object drawn last, not the nearest one (#2488)
select::pick said it returns the topmost entity within the aperture,
but it returned the nearest, using the draw order only to break ties.
AutoCAD 2024 picks the object drawn last among those under the pickbox,
and DRAWORDER changes which: a circle and a line drawn after it, 0.05
and 0.45 from the pick, give the line.

Candidates already come topmost first, so pick_with returns the first
within the aperture; pick_all keeps that order for selection cycling.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:01 -04:00
2ez4gcxandClaude Opus 5.5 2be869a154 Units and rtos: round a half away from zero, at 15 significant digits (#2484)
Decimal and scientific numbers went through Rust's formatting, which
rounds the exact binary value and a half to even: (rtos 1500.5 2 0)
gave 1500, (rtos 0.125 2 2) 0.12 and (rtos 1.005 2 2) 1.00.

AutoCAD 2024 gives 1501, 0.13 and 1.01: it rounds at 15 significant
digits, with a half away from zero, for scientific notation too
(2.5 to none is 3E+00). units::fixed does that on the decimal digits;
format_distance's decimal and scientific forms and rtos use it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:45:57 -04:00
2ez4gcxandClaude Opus 5.5 6ec6d97ea0 LISP: getvar and setvar take angle variables in radians (#2482)
* LISP: getvar and setvar take angle variables in radians

CADCraft keeps ANGBASE, SNAPANG, POLARANG, HPANG, CHAMFERD and
DIMJOGANG in degrees as far as sysvars::get/set go, which is right for
the SETVAR command, but AutoLISP's getvar returned those degrees and
setvar took them: (getvar "DIMJOGANG") gave 45.0, and
(setvar "ANGBASE" (/ pi 2)) set 1.57 degrees.

AutoCAD 2024 gives and takes them in radians from AutoLISP: each set to
30 by SETVAR, or to (/ pi 6) by setvar, reads 0.5235987755982987. The
LISP bridge now converts these six; the SETVAR command is unchanged.
getangle_is_relative_to_angbase sets ANGBASE in radians accordingly.

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

* SETVAR: keep real dimension variables real

A dimension variable whose style field is a real (DIMJOGANG, DIMSCALE
and the other real DIM* fields) is now stored as a real header value,
even when the first value set is a whole number. SETVAR DIMJOGANG 45
followed by (setvar "DIMJOGANG" 0.5) no longer rejects the fractional
degrees the radian value gives.

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

* SETVAR: store the value the dimension style took

A real dimension variable now stores the value its style field took after validation, so any input the style accepts (true for DIMSCALE gives 1) is stored as it was before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:45:53 -04:00
2ez4gcxandClaude Opus 5.5 3ba2362ec9 LISP: entity names answer prompts that pick one object (#2477)
* LISP: entity names answer prompts that pick one object

`command` fed an entity name, alone or as `entsel` returns it (name and
point), to the running command as a pick. Prompts that pick a single
object with a point (MEASURE, DIVIDE, LENGTHEN, PEDIT, BREAK, TRIM and
EXTEND in Standard mode, FILLET, OFFSET, REVCLOUD Object, dimensions…)
only take points, so routines got "Invalid input" or nothing at all.

At such a prompt the name is now entered as its point, and a pick at
exactly that point finds the named object (Session::pick_object), so
the point says where on it, as in AutoCAD. A bare name stands at the
object's DXF group 10 point, the point AutoCAD picks it at: a line's
start, a circle's or an arc's centre, a polyline's first vertex. At an
arc's centre MEASURE runs from the arc's end, as AutoCAD does.

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

* Keep a named object for its own input and its own space

A name given at a prompt picking one object now holds only while that
input is taken: a later pick at the same point finds what lies there.
A name for an object in another space picks nothing, instead of the
object under its point in the current space.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:45:50 -04:00
assamaralandClaude Sonnet 5.5 0ae424c771 fix(io): derive the column count of a dynamic auto-height MTEXT from its widths
An R2018 dynamic auto-height embedded object stores 0 in group 72 even with
several columns, and the clamp turned it into one. With 72 at zero the count is
now (42 + 45) / (44 + 45), the inverse of the total width the writer emits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:33:00 -03:00
assamaral b883d94bf7 Merge upstream/main into fix/mtext-embedded-columns-2248 2026-10-30 14:16:32 -03:00
ffishman 1e96a3d50c DXF: never let a trail byte 0x5C start an escape
In Shift-JIS, Big5 and GBK a double-byte character's trail byte can be 0x5C, the
backslash (表 95 5C, ソ 83 5C, 許 B3 5C, 乗 81 5C). Written as its bytes right
before U+ or M+, the file holds a backslash byte followed by U+, an escape for a
reader that looks for escapes before it decodes the code page. CADCraft decodes
first, so it read back right, but such a character there is now written as a
\U+XXXX escape and the file reads the same either way. Elsewhere it stays as
its bytes. ANSI_949 has no such trail byte.

Suggested by austin62jake on #1693.
2026-10-30 09:13:10 -03:00
Dylan M. Taylor f536bf406f UPD.1: honor pretend versions and restore dev-testkit checks (#2486) 2026-10-30 02:35:23 -04:00
Dylan M. Taylor d6de2db6db Update prompt shows the app icon and accent color instead of a letter (#2479)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#3ea6e8, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/constraints/src/auto.rs
- crates/engine/src/audit.rs
- crates/engine/src/cmd/inquiry.rs
- crates/engine/src/mtext_edit.rs
- crates/geom/src/nurbs.rs
- crates/io/src/dxf_raw.rs
- crates/io/src/dxf_read/document.rs
- crates/render/src/text.rs
- crates/ui-egui/src/cmdline.rs
- xtask/src/layers.rs
2026-10-30 01:28:46 -04:00
austin62jake dc0564a7a7 Merge branch 'main' into fix/text-middle-bottom-justification
# Conflicts:
#	docs/file-format-parity.md
2026-10-29 21:51:43 -05:00
Dylan M. Taylor f8b09e92e5 deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#2475)
- `zip`: 9.1.1 → 9.1.3

Lockfile only.
2026-10-29 22:22:29 -04:00
Dylan M. Taylor b3158b56a8 CI: workflows only call craft-common-actions at @main (#2473)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:14:18 -04:00
Brandon ThomasandClaude Opus 5.5 7546277128 fmt: rustfmt the DIMTMOVE 2 clearance port (#2302)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:39:34 -04:00
2d2be155dd fix(engine): refresh attribute baselines after value changes (#2214)
* xtask: the test suite and clippy pass on Windows

Two Windows-only failures stopped `cargo xtask ci` before it got going on
a Windows checkout (CI runs the gate on Linux only, so neither showed):

- parity::tests::civil_dates built a SystemTime 115,740,740 days after
  the epoch (year 318857). Windows' SystemTime ends in the year 30828, so
  the addition panicked ("overflow when adding duration to `SystemTime`").
  That case now checks `civil_from_days` on the day count, which is what
  it was about, on every platform.
- stats.rs imported `walk` for a test that only exists on Unix, so the
  import was unused on Windows and `clippy -D warnings` refused it. The
  Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`.

Checked on Windows 11 x64: `cargo test --workspace` and
`cargo clippy --workspace --all-targets -- -D warnings` both pass.

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

* fix(engine): refresh attribute baselines after value changes

CC-046: invalidate the derived XY baseline for changed justified attribute
values, including small changes inside the writer's imported-font tolerance.
Preserve no-op placement, alignment anchors and Aligned/Fit endpoints.

Regression reproduced on current upstream; all three focused tests pass.
Full cargo xtask ci passed all seven stages on Windows (2558 passing test
executions, zero failures, one ignored). Release Trunk web build passed.
Browser interaction verification is pending user dismissal of a Windows
Security prompt; do not treat the build as a runtime pass.

Windows validation prerequisite: original upstream PR #2198 commit a2dd814.
Independent of editor/ribbon changes; reverting restores only this setter,
its regression tests and corresponding file-format parity record.

* fix(io): preserve insertion elevation when refreshing text baselines

CC-046 review follow-up to PR #2214 comment 4377706777. Regression reproduced insertion Z 9 becoming 7. Preserve group 10 Z; test DXF independent elevations and document the DWG bridge shared elevation. Full cargo xtask ci passes all seven gates. This separate commit is reversible; reverting it restores elevation loss during baseline recomputation. Existing browser UI verification predates this writer correction.

* test(engine): cover global attribute no-ops and two-point exports

CC-046 review 5653054817: verify equal-value global JSON and scripted
-ATTEDIT preserve imported placement, and Aligned/Fit DXF save/reopen
retain both placement points and justification.

Validation: cargo xtask ci passed all seven gates on this branch;
git diff --check passed. Test-only follow-up, no application change.
Depends on CC-046 and unchanged Windows prerequisite #2198.
Revert this commit independently to remove only these assertions.
Runtime evidence remains separately recorded for the production commits.

---------

Co-authored-by: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:38:15 -04:00
BooksandBrandon Thomas f883454d7d DWG: post-read fix-ups run under catch_unwind; merge work is bounded (#1934) (#2402)
* DWG: post-read fix-ups run under catch_unwind; merge work is bounded (#1934)

table_merges and the other fix-ups on the acadrust document ran outside the
panic guard, and each merge range could visit rows x cols cells with no total
limit. They now run in one guarded post_process, and table_merges stops at
MAX_MERGE_CELLS cells in all. Tests: merge_work_is_bounded_by_the_cell_budget,
a_panic_in_post_processing_is_an_error.

* Parity checklist: regenerated after merging main

* DWG: test that the read and convert guards catch a panic through the public API (#2402)

The existing test only exercised guarded() itself. A test-only hook now makes post_process
or convert panic on entry, and tests go through dwg_to_dxf and dxf_to_dwg: both fail if
their guard is removed. convert()'s fix-ups, which run on the third-party reader's
document, are now under guarded() too, with their own error.

* Parity checklist: regenerated after merging main

* DWG: build the panic-test helpers only on native targets, like the tests that use them (#2402)

PanicIn and MINIMAL_DXF referenced the native-only PANIC_IN, so the dwg tests did not compile
for wasm32 (cargo check -p cadcraft-dwg --tests --target wasm32-unknown-unknown).

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:37:44 -04:00
9b4c1fb840 Reject negative or non-finite JSON chamfer distances (#2406)
Found while testing crates/engine/src/cmd/modify/fillet.rs:120-121 (run_chamfer) and 53-54 (fillet_lines)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:33 -04:00
BooksandBrandon Thomas 912558635e DWG: keep block draw-order sort handles through the DXF bridge (#2410) (#2439)
* DWG: keep block draw-order sort handles through the DXF bridge (#2410)

acadrust's DXF writer renumbers SORTENTSTABLE sort handles past every entity
handle, so an entity left out of a table (sorted by its own handle) drew below
every listed one. The table now carries its codes verbatim before writing.
Tests in io/tests/it/sortents.rs; the bridge test fails without the fix.

* Parity checklist: regenerated after merging main

* DXF: a block/space sort handle of 0 draws last (#2439)

A SORTENTSTABLE sort handle of 0 means the entity is drawn after every other
(ezdxf reorder.py, which notes AutoCAD defines it). apply_sortents sorted it
first; key it after all nonzero handles. Test: a_zero_sort_handle_draws_last (DXF and DWG bridge).

* Parity checklist: regenerated after merging main

* Draw order: test two block entities left out of a SORTENTSTABLE (#2439)

Each unlisted entity must sort by its own handle, landing between the listed ones. A
single fallback key for all of them would put them side by side. Checked in a DXF and
across the DWG bridge; test-only.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:29 -04:00
42667d1332 ELLIPSE from axis endpoints matches what AutoCAD stores (#2197)
* ELLIPSE from axis endpoints matches what AutoCAD stores

Typed ELLIPSE input was checked against AutoCAD 2024 black-box (synthetic
input in accoreconsole, read back with entget); three rules differed:

- The major axis pointed at the second axis endpoint; AutoCAD points it at
  the first. A full ellipse looks the same, but an elliptical arc's angles
  are measured from that axis, so the arc was drawn on the opposite side of
  the center, and the stored major vector, start point and quadrant order
  all differed.
- A point at "Specify distance to other axis" gave its distance from the
  first axis; AutoCAD takes its distance from the center (the rubber band),
  then swaps the axes when it is the longer one.
- The end-angle prompt had no Included angle option, so `_I` was rejected
  and the next number became an absolute end angle. AutoCAD adds the
  included angle (default 180) to the start angle.

The JSON form (ellipse.axis / ellipse.arc with p1, p2, p3) follows the
same rules. The regression test pins ten AutoCAD-measured cases, among
them survey (VN-2000) coordinates, a near-millimetre ellipse and both
Included angle cases.

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

* docs: geometry parity says which part was measured against AutoCAD

The "How this was measured" section still said no numeric comparison
against AutoCAD had been run, next to the new ELLIPSE revision row that
reports one. It now names the 11 measured ELLIPSE cases and the test
that pins them, and keeps the rest of the document marked as read from
the source. Pointed out in review.

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

* ellipse: drop the import the axis-endpoint change left unused

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:26 -04:00
BooksandBrandon Thomas d15de75406 Dimensions: report the measurement as soon as one is created (#2421) (#2438)
* Dimensions: fill in the measurement when one is created (#2421)

A dimension made in the session kept measurement 0.0 until it was saved and reopened.
add_dim now sets it with the geometry call the DXF/DWG writer uses for group 42.
Block names are still assigned at save time. Test: a_new_dimension_reports_its_measurement.

* Parity checklist: regenerated after merging main

* Dimensions: a jogged radius dimension stores its radius as the measurement (#2421)

draw_jogged_radius set the text but not DimGeometry.measurement, so DIMJOGGED dimensions
kept measurement 0 (and the DXF/DWG group 42 was 0). It now sets the radius.
Test: jogged_radius asserts the stored measurement.

* Parity checklist: regenerated after merging main

* Dimensions: fill in a new dimension's text position when it is created (#2421)

A dimension made in the session kept text_mid (0,0) until it was saved and reopened.
add_dim now sets it from the same dimension_geometry result as the measurement, the
position the writer puts in group 11, unless the user placed the text.
Test: a_new_dimension_reports_its_text_position.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:22 -04:00
dcbcb6ab32 fix: keep WIPEOUT closure coherent across grips and outlines (#2284)
* fix: keep WIPEOUT closure coherent across grips and outlines

Treat WIPEOUT as a closed polygon with an optional exact terminal closure marker, not as an open polyline. Centralize logical vertices, explicit outlines and closure-preserving vertex edits in the document type. Reuse this contract in grips, geometric primitives, rendering and DXF export without changing the stored schema or writer closure convention.

Add six-version DXF ASCII/binary and DWG regressions for implicit/explicit closure, both windings, all corners, API/interactive edits, undo/redo and repeated saves. Keep open/closed polylines and their coincident endpoints distinct. Cover multiple hot grips, hostile inputs and equivalent mask/frame output.

Validation: cargo xtask ci, all seven stages passed (2571 tests passed, one pre-existing skip). Two unrelated network tests failed transiently in earlier runs; each passed in isolation and the complete final run passed without exclusions. Direct and reopened grip edits produce byte-identical CLI PNG output.

Fixes #2256; related test-only matrix PR #2237 and tracker #2238 B3.

* fix(doc): keep coincident WIPEOUT endpoints separately editable

When an edit makes the endpoints of an implicit boundary coincide, add a separate closing marker rather than reclassifying the edited corner as that marker. Retain the logical vertex count and allow either coincident corner to move independently afterwards.

The document regression fails before the fix. Add API/interactive coverage for both endpoint directions and both closure forms, undo/redo, and subsequent separate edits across all six DXF/DWG versions including binary DXF and save-after-edit. Update the documented editing contract.

Validation: cargo xtask ci passed all seven stages; 2573 tests passed, one pre-existing skip.

Addresses the coincident-endpoint review on PR #2284; related #2256.

---------

Co-authored-by: cloudj <jonbahnes@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:17 -04:00
0d9e62bc9a Stop DIMTMOVE 2 text taking the DIMTAD 1 clearance twice (#2302)
Fixes #2222

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:13 -04:00
3243271d4e Reject zero distance in JSON offset command (#2190)
Found while testing crates/engine/src/cmd/modify.rs:791 (run_offset)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:31:09 -04:00
Max MittelstädtandGPT-6-Luna 303bc97f9a Count DXF group codes correctly when enforcing limits (#2408)
Found while testing crates/dxf/src/lib.rs:185

Co-authored-by: GPT-6-Luna <noreply@openai.com>
2026-10-29 21:22:01 -04:00
NetterSilverandClaude Haiku 5.5 b9b7b3d439 Refuse invalid count in JSON copy instead of clamping it (#2294)
Found while testing crates/engine/src/cmd/modify/transform.rs:69 (run_copy)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:58 -04:00
1d987904f9 Tests remove the temp files they write (#2232)
* Tests remove the temp files they write

Every test run left about 22 folders and files in the system temp
directory: xref, alias, template, insert, linetype, script and
recent-file fixtures, plus the pathless screenshot folder. The PDFBOOK
test also wrote its second book to `$TMPDIR/b.pdf`, because `a^b.pdf`
splits into `<prefix>-a.pdf` and `b.pdf`, and then removed a file that
was never written.

Tests that write files now use a `TempDir` guard
(`tests/support/temp_dir.rs`, shared by the engine's unit and
integration tests and copied for the UI crate). It gives each test its
own folder and removes it on drop, even when an assertion fails. The
PDFBOOK test also checks that both books land in its folder. The five
tests that write pathless screenshots hold a test-only lock, so the
shared screenshot folder can be removed once empty without racing
another test's write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012FFPi6hJVyaEKV4PyE2fKq

* Pathless screenshot cleanup survives failed tests and writes

The test lock's guard now empties and removes the per-process
screenshot directory when dropped, so a test that panics no longer
leaves files behind, and the per-test remove calls are gone. A failed
PNG write or flush removes the partial file, since the reply carries
no path for anyone else to clean up.

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

---------

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:54 -04:00
Books 1d6c72db11 Bound the MCP client's reply read and the control server's total buffered bytes (#1925) (#2398)
* Bound the MCP client's reply read and the control server's total buffered bytes (#1925)

The MCP client read replies with an unbounded read_line; a process squatting on the
control port could make it allocate without limit. It now reads through take() with a
256 MiB cap (the server's own request cap) and fails with a clear error.

The control server allowed max_clients x max_line (16 x 256 MiB) buffered at once.
Request-line bytes are now charged against a shared 512 MiB budget; a request that
would pass it gets an error reply and the connection closes.

* Parity checklist: regenerated after merging main

* control_server: drop unused Read import (#1925)

* control_server: tests import Read themselves (#1925)

The unused-import cleanup removed std::io::Read from the module, but a test still calls
read_to_end, so the test build failed on CI.
2026-10-29 21:21:50 -04:00
NetterSilverandClaude Haiku 5.5 61fefc3ed9 Skip a leading UTF-8 BOM when parsing ASCII DXF (#2287)
Found while testing crates/dxf/src/lib.rs:189 and 267-274 (parse_with_limit via decode_ascii_file, then parse_ascii_with_limit)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:46 -04:00
74e77f8a92 feat(web): persist validated browser panel layouts (#2258)
* xtask: the test suite and clippy pass on Windows

Two Windows-only failures stopped `cargo xtask ci` before it got going on
a Windows checkout (CI runs the gate on Linux only, so neither showed):

- parity::tests::civil_dates built a SystemTime 115,740,740 days after
  the epoch (year 318857). Windows' SystemTime ends in the year 30828, so
  the addition panicked ("overflow when adding duration to `SystemTime`").
  That case now checks `civil_from_days` on the day count, which is what
  it was about, on every platform.
- stats.rs imported `walk` for a test that only exists on Unix, so the
  import was unused on Windows and `clippy -D warnings` refused it. The
  Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`.

Checked on Windows 11 x64: `cargo test --workspace` and
`cargo clippy --workspace --all-targets -- -D warnings` both pass.

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

* feat(web): persist validated browser panel layouts

CC-053: keep embedded floating, docked and hidden panels across reloads
through the existing host save cycle. Reject malformed/versioned records
atomically; native workspaces and drawings remain unchanged.

Validation: all seven cargo xtask ci gates passed (2558 pass, 0 fail,
1 ignored) on a2dd814 plus this exact change. Actual release Trunk bundle
and Edge floating/close/reload/reset/reload passed. Forced WebGL startup,
render and recovered layout passed; full edit cycle used default mode.

Dependency: original #2198 Windows test prerequisite. No #2226 dependency.
Revert this commit independently to stop using the optional storage key.
Upstream c3b8e7b adds nextest minimum configuration only; no runtime overlap.

---------

Co-authored-by: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:42 -04:00
heejunB f817ea9a3a fix(render): fill font glyphs with the nonzero winding rule (#2189)
TrueType and OpenType outlines are defined with the nonzero rule, but glyph
meshes were tessellated even-odd, so any area covered by two contours came
out empty. Arial Unicode (the macOS fallback CADCraft picks for Hangul)
builds 진 (U+C9C4) from a component that already contains the final ㄴ plus
a second copy of that ㄴ; even-odd cancelled the pair and 진 rendered as 지.
Overlapping contours in other fonts lost their overlaps the same way.

Tessellate glyph outlines with FillOptions::non_zero(). Outlines placed in
drawing coordinates without a TextGlyph (complex-script runs) go through the
same tessellator, offset to their first point to keep f32 precision, instead
of the even-odd slab filler.
2026-10-29 21:21:38 -04:00
Ayoze Torres 31dba6e7a7 Command line: a LISP value at a prompt is the answer, not another expression (#1920) (#2338)
An expression typed at a command's prompt is evaluated, and its value
answers the prompt. That value went back through typed(), which treated
it as typed text and evaluated it again when it looked like LISP. So
`circle (setq q "!q")` returned "!q", which evaluated to "!q" again,
forever, until the native stack overflowed and the process aborted
(a stack overflow is not a panic, so the catch_unwind guards cannot
help), losing unsaved work in every open drawing. It is reachable from
the command line, .scr scripts, the control channel and MCP.

The value now goes back with LISP evaluation off: a string that looks
like an expression is taken as text, as AutoCAD does.
2026-10-29 21:21:35 -04:00
NetterSilverandClaude Haiku 5.5 58687dda2d Reject out-of-range numeric colours in properties.set (#2286)
Found while testing crates/engine/src/cmd/props.rs:155-157 (parse_color), with crates/color/src/lib.rs:60-66 (Color::from_aci)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:31 -04:00
3ccfad60dc Block Editor: BEDIT inside it switches to another block, asking to save first (#2219)
* Block Editor: BEDIT inside it switches to another block, asking to save first

BEDIT (typed, Edit Block, Tools > Block Editor) no longer refuses while the
Block Editor is open: the Edit Block Definition dialog opens on the block being
edited, and choosing another block switches to it. With changes not saved yet
it first asks whether to save them (Yes saves, No discards, Cancel stays), at
the command line as BCLOSE does and in the dialog. The JSON form takes
`save: true|false` and refuses to switch away from unsaved changes without it;
the block to switch to is checked before anything is saved or closed.

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

* Block Editor: the save question is one translatable sentence

The block name and punctuation were added around a translated fragment, so a
translation couldn't place the name or use its own quotation marks.

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

* BEDIT: a block the Block Editor can't switch to is refused before the save question

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:27 -04:00
2ez4gcxandClaude Opus 5.5 35b4332ee2 MEASURE picks its starting end by the curve's parameter, as AutoCAD does (#2207)
* MEASURE picks its starting end by the curve's parameter, as AutoCAD does

MEASURE started from whichever end was nearer the pick in a straight
line, and a closed curve always ran forwards from its first vertex.
AutoCAD (2024, black-box: synthetic objects in accoreconsole, every
POINT read back with entget) decides by the curve parameter of the pick,
the segment index plus the fraction along it:

- an open curve is measured from its end once the pick is halfway or
  further: an L-shaped polyline picked at (10, 30) on its long leg
  (parameter 1.3 of 2) runs from the end, although that end is the
  farther one; a U-shape picked at (90, 0) (parameter 0.9 of 3) runs from
  the start, although the end is nearer;
- a closed polyline runs from its first vertex, forwards before half its
  parameter and backwards from then on;
- a circle always runs counterclockwise from angle 0.

Chain::param_of gives that parameter. The new test
divide_and_measure_match_autocad pins nine measured DIVIDE and MEASURE
cases (lines, arcs, circles, open and closed polylines with an arc
segment), every point to 1e-9.

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

* MEASURE: a spline's starting end comes from its own parameter

A spline's chain is a tessellation with zero-length pieces dropped, so
its segment count is not the spline's parameter range: a degree-1 spline
whose first span has zero length spends half its parameter there, and a
pick at x = 3 on its 10-unit run (parameter 1.3 of 0..2, past the middle)
was measured from the start because it is short of the drawn middle.
Splines now compare spline_param_of with the middle of their domain;
other curves keep Chain::param_of. Pointed out in review.

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

* MEASURE: polylines count coincident vertices in their parameter; ellipses pinned

AutoCAD's polyline parameter is the vertex index, so a coincident vertex
still takes one unit of the range. The chain drops zero-length spans, so
its segment index and count shifted the middle: (0,0) (1,0) (101,0)
(101,0) picked at x = 1.1 (parameter 1.001 of 3) was measured from the
end, and (0,0) (0,0) (10,0) (20,0) picked at x = 7 (1.7 of 3) from the
start. LWPOLYLINEs and 3D polylines now take the parameter from their
source vertices (curves::vertex_param_of). Pointed out in review.

Ellipses keep the parameter rule, which AutoCAD follows for them too:
a full ellipse picked at t = pi and an elliptical arc picked past its
middle both run from the end in AutoCAD 2024 (MEASURE-032 and -033 of the
measured set; the other direction is 200 and 4 units off). The new test
pins both, within the 1e-4 tessellation CADCraft measures along.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:24 -04:00
2ez4gcxandClaude Opus 5.5 9a1f200795 xtask: the test suite and clippy pass on Windows (#2198)
* xtask: the test suite and clippy pass on Windows

Two Windows-only failures stopped `cargo xtask ci` before it got going on
a Windows checkout (CI runs the gate on Linux only, so neither showed):

- parity::tests::civil_dates built a SystemTime 115,740,740 days after
  the epoch (year 318857). Windows' SystemTime ends in the year 30828, so
  the addition panicked ("overflow when adding duration to `SystemTime`").
  That case now checks `civil_from_days` on the day count, which is what
  it was about, on every platform.
- stats.rs imported `walk` for a test that only exists on Unix, so the
  import was unused on Windows and `clippy -D warnings` refused it. The
  Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`.

Checked on Windows 11 x64: `cargo test --workspace` and
`cargo clippy --workspace --all-targets -- -D warnings` both pass.

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

* Contributors: add 2ez4gcx (Doan Huynh Huu Khuong)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:20 -04:00
Books bcf0dbc5e8 DWG: keep the current layer (CLAYER) through a save and reopen (#2252) (#2407)
* DWG: keep the current layer (CLAYER) through a save and reopen (#2252)

The DWG header stores the current layer as a handle, and the one read from the DXF
needn't be the layer's in the document, so the drawing reopened on layer 0.
The bridge now resolves CLAYER by name to the layer's handle before writing, like
the current text and dimension styles. Test: the_current_layer_round_trips_through_dwg.

* Parity checklist: regenerated after merging main

* DWG: resolve $CLAYER through the layer table's Unicode-aware lookup (#2252)

eq_ignore_ascii_case missed non-ASCII case differences such as Survey_Ä / Survey_ä,
leaving layer 0 current. acadrust's Table::get normalises names with to_uppercase.

* DWG: test a current layer named in other letter case (#2252)
2026-10-29 21:21:15 -04:00
NetterSilverandClaude Haiku 5.5 c2695f6ab0 Refuse invalid counts in JSON arraypolar and arrayrect (#2399)
Found while testing crates/engine/src/cmd/array.rs:332-334 (count_or, used by run_arrayrect at 338-340 and run_arraypolar at 363-365)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:10 -04:00
NetterSilverandClaude Haiku 5.5 7efb32b455 Cap autosave file names at 255 bytes for multibyte titles (#2289)
Found while testing crates/engine/src/autosave.rs:61-66 (file_name)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:06 -04:00
NetterSilverandClaude Haiku 5.5 ab8953067a Reject out-of-range LUNITS, LUPREC, AUNITS and AUPREC in SETVAR (#2269)
Found while testing crates/engine/src/sysvars.rs:379-420 (generic fallthrough in set); the valid ranges are enforced only in crates/engine/src/cmd/props.rs:611-616 (run_units)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-29 21:21:02 -04:00
aldozarandClaude Opus 5.5 afc12e4dce PEDIT Multiple at the command line (#2225)
* PEDIT Multiple at the command line (#2182)

`M` at "Select polyline" answered that Multiple was only available through the JSON form. It now
works as in AutoCAD: select objects (polylines, lines and arcs), turn the lines and arcs into
polylines on Yes (left out on No), then each option applies to all of them: Close, Open, Width,
Fit, Spline, Decurve, Ltype gen and Reverse. Join joins the selected objects with each other,
ends within a fuzz distance, and reports how many it added. Undo takes the last option back.

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

* Geometry parity status line for PEDIT Multiple

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

* PEDIT Multiple: objects selected first, and Decurve after Fit or Spline

Several objects selected before PEDIT go straight into Multiple, as in AutoCAD, and M keeps a
selection made first. Decurve gives back each polyline's vertices from before its first Fit
or Spline, as single-polyline PEDIT does; a Join drops that record for the joined polylines.

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

* PEDIT Multiple: the Decurve record follows Undo and survives a Join that joins nothing

Each Undo step keeps the record of the vertices from before Fit or Spline, so Fit, Join, Undo,
Decurve gives them back; a Join drops the record only for a polyline that took on segments.

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

* PEDIT: Yes/No asked again on another answer; Decurve keeps a later Width

The convert questions (single and Multiple) take Y/Yes, N/No or Enter and ask again for any
other answer instead of treating it as Yes. Width clears the per-vertex widths of the vertices
kept for Decurve too, so Decurve after Fit or Spline and Width keeps the new width.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:20:58 -04:00
assamaralandClaude Sonnet 5.5 c5d186ae92 fix(io): the embedded column object carries the column height like AutoCAD's (#2248)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 16:07:47 -03:00
assamaralandClaude Sonnet 5.5 9ff37ba262 fix(io): an MTEXT's R2018 embedded column object is kept and its groups stay out of the entity (#2248)
The reader took the embedded object's 44 (column width) as the MTEXT's line spacing factor and
dropped its columns; the writer wrote no embedded object, the legacy column groups (which
R2018 ignores) and a drawing direction of 1 instead of 5 (by style). Now the embedded object
is read (type, count, width, gutter, heights), written for R2018 and later, and the MTEXT's
own groups are the ones before it. AutoLISP entget keeps the legacy groups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 15:55:52 -03:00
assamaralandClaude Sonnet 5.5 4226a2610a test(io): an MTEXT's R2018 embedded column object is read and written (#2248)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 15:37:34 -03:00
austin62jakeandClaude Opus 5.5 263eba2ef6 Test bottom-justified TrueType text with any common installed font
The test used Arial only, so it skipped on Linux and macOS CI. It now
uses the first of Arial, Helvetica, DejaVuSans, Verdana or Liberation
Sans that is installed (as the fonts crate's other TrueType tests do),
and keeps the Arial-specific checks for Arial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:15:20 -05:00
austin62jakeandClaude Opus 5.5 b17ae78cec fix(fonts): place Middle and bottom-justified text as AutoCAD does (#2229)
A justified TEXT's insertion point (group 10) is computed from its alignment point, and AutoCAD
takes it from a DWG as it is (it recomputes it only for DXF). CADCraft placed a Middle (M) text
at half its height and a bottom-justified one a third of its height below the baseline,
whatever the string or font, so in AutoCAD those texts were drawn off their alignment points:
0.35 at height 2.5 for "Tg 1234 ABC" justified M, 0.09 for BL with Arial (#2229).

- Middle (M) now centres the text on its own ink, descenders included, as AutoCAD does. Text
  that draws nothing keeps half the height. Middle Center (MC) and the others are unchanged.
- Bottom justifications with a TrueType face put the alignment point at the face's descent
  (its descent over its cap height, the text height): 0.296 of the height for Arial, where
  AutoCAD puts it. The stroke font keeps a third, which matches AutoCAD's SHX fonts.

`frame` (the DWG insertion point) and `place` (drawing) share the new `justify_offset`, so
what is drawn and what is saved agree. With the reporter's commands, the saved points are now
within 0.0006 of AutoCAD's: M -0.9032 (AutoCAD -0.9038), BL -19.2604 (-19.2599).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:01:18 -05:00
Dylan M. Taylor 336243e0af Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#2425)
Add packaging/freebsd/package.sh, matching the other craft apps: an optional
CARGO_BUILD_TARGET (x86_64 or aarch64 FreeBSD) names the archive and reads the
cross-built binaries; unset means native. --skip-build and --dry-run supported.
2026-10-28 15:53:50 -04:00
Dylan M. Taylor 9de2b8bbdc Grant network access so update checks can reach GitHub (#2413)
Add --share=network to the finish-args of both Flatpak manifests. Without it
the sandbox cannot resolve or reach GitHub, so update checks fail with
"Temporary failure in name resolution". The two manifests keep identical
finish-args.
2026-10-28 13:23:09 -04:00
ffishmanandClaude Opus 5.5 8b8e921f6a Merge upstream main into fix/dxf-codepage-text
main split dxf_write.rs into dxf_write/ (736a834): the code_page change moves to
dxf_write/header.rs, re-exported for the writer in lib.rs. No logic change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:40:33 -03:00
ffishmanandClaude Opus 5.5 069d276fe5 Merge upstream main into fix/dxf-codepage-text
crates/dxf/src/lib.rs: #2050's rule (a backslash that would start \U+ or \M+ is
written as \U+005C) goes into push_string, so it holds for UTF-8, code page and
pure ASCII output, the UTF-8 fallback included. A test checks literal escapes
next to code page text in ASCII and binary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 09:24:22 -03:00
NetterSilverandClaude Haiku 5.5 4fdde4f301 Update symlink target in write_atomic instead of replacing the link
Found while testing crates/engine/src/atomic_write.rs:33-58 (write_atomic)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:40:24 +00:00
ffishmanandClaude Opus 5.5 0646dca86e DXF: escape ASCII output whose code page bytes are valid UTF-8
An ASCII DXF that is valid UTF-8 is read as UTF-8, so pre-2007 text such as
СІ in ANSI_1251 (D1 B2) came back as U+0472. write_ascii_in now falls back to
\U+ escapes for such a file; binary DXF, read by its header's code page,
keeps the bytes. Reported by CodeRabbit on #1693.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:05:36 -03:00
ffishmanandClaude Opus 5.5 ea02e45099 Merge upstream main into fix/dxf-codepage-text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:04:28 -03:00
ffishmanandClaude Opus 5.5 7af20d0bb2 Merge upstream main into fix/dxf-codepage-text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:58:00 -03:00
Rodrigo GresslerandClaude Opus 5.5 936f08db1f DXF: keep files that declare ASCII or UTF8 pure ASCII when saving
A pre-2007 file whose header says $DWGCODEPAGE ASCII (or UTF8/UNICODE, which
no pre-2007 reader expects) is still written with \U+ escapes only, instead of
Windows-1252 (or UTF-8) bytes the header doesn't declare. Review of #1693.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 22:25:35 -03:00
Rodrigo GresslerandClaude Opus 5.5 28fe95fff0 DXF: save pre-2007 text in the file's code page instead of all \U+ escapes
Before 2007, DXF text is in the code page `$DWGCODEPAGE` names. Since #169
every non-ASCII character was written as a `\U+XXXX` escape, so a Portuguese
drawing saved at 2000/2004 had layers, blocks and text such as
`A\U+00E7\U+00E3o` in the file. Characters the declared code page has are now
written as its bytes (`Ação` in ANSI_1252); the others are still escaped, and
an unknown code page still gives pure ASCII. The DWG bridge is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:36:13 -03:00
195 changed files with 14097 additions and 718 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: cadcraft
app-dir: CADCraft
+8 -3
View File
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -47,7 +47,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## Contributor credits (About window)
@@ -66,3 +66,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+1
View File
@@ -65,6 +65,7 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker |
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
+8 -3
View File
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -46,4 +46,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+28 -27
View File
@@ -268,7 +268,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -643,7 +643,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -659,14 +659,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -676,7 +676,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,14 +687,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"encoding_rs",
"log",
@@ -703,7 +703,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -724,7 +724,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
@@ -734,14 +734,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -762,7 +762,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -775,7 +775,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -789,7 +789,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -815,7 +815,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -1191,13 +1191,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1642,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3434,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4029,7 +4030,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4086,7 +4087,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4561,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5470,7 +5471,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5893,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.15.1"
dependencies = [
"chrono",
"serde_json",
@@ -6114,9 +6115,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.1"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
+3 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.14.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -65,7 +65,8 @@ eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+13 -1
View File
@@ -14,6 +14,9 @@ pub mod download;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod panel_layout;
#[cfg(target_arch = "wasm32")]
mod web {
use cadcraft_engine::Session;
@@ -59,10 +62,14 @@ mod web {
self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
match crate::panel_layout::save(&self.0.ui) {
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
Err(error) => log::warn!("panel layout was not saved: {error}"),
}
}
fn persist_egui_memory(&self) -> bool {
false
@@ -160,6 +167,11 @@ mod web {
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
app.load_prefs(&prefs);
}
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
{
log::warn!("panel layout was not restored: {error}");
}
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
if !crate::wants_cpu(&query())
&& let Some(rs) = &cc.wgpu_render_state
+104
View File
@@ -0,0 +1,104 @@
//! Browser panel layout storage, independent of drawings and native workspace files.
use cadcraft_ui_egui::{UiState, docking};
use serde_json::{Value, json};
#[cfg(target_arch = "wasm32")]
pub const KEY: &str = "cadcraft.panel-layout";
const MAX_BYTES: usize = 64 * 1024;
/// Serialize only validated panel placement and visibility, with a bounded record size.
pub fn save(ui: &UiState) -> Result<String, String> {
docking::validate(&ui.docking)?;
let saved = json!({
"version": docking::LAYOUT_VERSION,
"docking": ui.docking,
"showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes,
})
.to_string();
if saved.len() > MAX_BYTES {
return Err("panel layout exceeds 64 KiB".into());
}
Ok(saved)
}
/// Validate the entire optional record before changing any UI field.
pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {
if saved.len() > MAX_BYTES {
return Err("panel layout exceeds 64 KiB".into());
}
let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;
if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {
return Err("unsupported panel layout version".into());
}
let workspace: docking::Workspace =
serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;
docking::validate(&workspace)?;
let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;
let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;
ui.docking = workspace;
ui.show_toolsets = tools;
ui.show_palettes = palettes;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
#[test]
fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {
let mut source = CadApp::new(Session::default(), Services::default());
source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();
source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
let saved = save(&source.ui).unwrap();
let mut destination = CadApp::new(Session::default(), Services::default());
destination.session.start("line").unwrap();
let before = destination.session.doc().unwrap().clone();
restore(&mut destination.ui, &saved).unwrap();
assert_eq!(destination.ui.docking, source.ui.docking);
assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);
assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);
assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
assert_eq!(destination.session.doc().unwrap(), &before);
}
#[test]
fn malformed_or_unsupported_storage_is_rejected_atomically() {
let mut ui = UiState::default();
let before = serde_json::to_value(&ui).unwrap();
let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
for (field, bad) in [
("version", json!(0)),
("version", json!(docking::LAYOUT_VERSION + 1)),
("docking", json!({})),
("showToolsets", json!("false")),
("showPalettes", Value::Null),
] {
let mut invalid = valid.clone();
invalid[field] = bad;
assert!(restore(&mut ui, &invalid.to_string()).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {
assert!(restore(&mut ui, &invalid).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
}
#[test]
fn storage_cannot_remove_the_document_view() {
let mut ui = UiState::default();
let before = serde_json::to_value(&ui).unwrap();
let mut invalid = ui.clone();
invalid.docking.layout.root = None;
assert!(save(&invalid).is_err());
let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
record["docking"] = serde_json::to_value(&invalid.docking).unwrap();
assert!(restore(&mut ui, &record.to_string()).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
}
+140 -31
View File
@@ -7,7 +7,7 @@
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -24,6 +24,9 @@ struct Limits {
max_clients: usize,
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
max_line: usize,
/// Request-line bytes buffered at once across all connections; a request that would pass it
/// is refused with an error reply.
total_bytes: usize,
/// Requests waiting for the app at once; more are answered `busy`.
queue: usize,
/// How long a connection may go without sending a byte before it is closed.
@@ -40,6 +43,8 @@ impl Default for Limits {
max_clients: 16,
// Generous, so requests that carry whole drawings or images as base64 still fit.
max_line: 256 * 1024 * 1024,
// Room for one maximal request plus smaller ones, not `max_clients` maximal ones.
total_bytes: 512 * 1024 * 1024,
queue: 64,
idle_timeout: Duration::from_secs(600),
write_timeout: Duration::from_secs(30),
@@ -106,6 +111,7 @@ impl Drop for Slot {
/// Accept clients within the connection limit and give each worker the same session token.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
let budget = Budget::new(limits.total_bytes);
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
@@ -117,39 +123,102 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let budget = budget.clone();
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx, limits, &token);
serve(stream, &tx, &ctx, limits, &token, budget);
});
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
/// A total-bytes budget for request lines buffered across all connections, so
/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925).
#[derive(Clone, Debug)]
struct Budget {
used: Arc<AtomicUsize>,
total: usize,
}
impl Budget {
fn new(total: usize) -> Self {
Self { used: Arc::new(AtomicUsize::new(0)), total }
}
/// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total.
fn reserve(&self, n: usize) -> bool {
self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok()
}
}
/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the
/// next line starts or the connection ends.
struct Held {
budget: Budget,
bytes: usize,
}
impl Held {
fn new(budget: Budget) -> Self {
Self { budget, bytes: 0 }
}
fn release(&mut self) {
self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst);
self.bytes = 0;
}
}
impl Drop for Held {
fn drop(&mut self) {
self.release();
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging
/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long,
/// the shared budget is used up, or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, held: &mut Held) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
held.release();
// A line is at most `max` bytes plus its newline; never buffer more than that.
let cap = max.saturating_add(1);
loop {
let chunk = match reader.fill_buf() {
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => {
return Err(match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
});
}
};
if chunk.is_empty() {
// End of stream: nothing read, or a last line without a newline.
return Ok(!buf.is_empty());
}
let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1);
let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len()));
buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk));
reader.consume(n);
// The chunk is already buffered (a few KiB), so it is added before it is charged: a
// refusal still leaves `buf` non-empty and the connection gets an error reply.
if !held.budget.reserve(n) {
return Err("too many large requests in flight; try again later".to_string());
}
held.bytes += n;
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// One request line, parsed.
@@ -204,7 +273,7 @@ fn is_http_request_line(text: &str) -> bool {
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -212,8 +281,9 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
let mut held = Held::new(budget);
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) {
Ok(false) => break,
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
// A line nobody asked for would be read as the reply to the client's next request.
@@ -264,6 +334,7 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
@@ -281,13 +352,13 @@ mod tests {
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream");
}
#[test]
@@ -295,11 +366,15 @@ mod tests {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
fn held() -> Held {
Held::new(Budget::new(usize::MAX))
}
fn short() -> Limits {
Limits {
queue: 1,
@@ -414,6 +489,40 @@ mod tests {
drop(rx);
}
/// Connections can't together buffer more request-line bytes than the budget: a second large
/// request is refused with an error reply while the first still holds its bytes (#1925).
#[test]
fn requests_past_the_total_budget_are_refused() {
let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() });
// The first peer sends 200 bytes of an unfinished line and keeps the connection open.
let mut first = TcpStream::connect(addr).unwrap();
first.write_all(&[b'a'; 200]).unwrap();
std::thread::sleep(Duration::from_millis(200));
// The second would take 200 more, past the 300 byte total.
let mut second = TcpStream::connect(addr).unwrap();
second.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
second.write_all(&[b'b'; 200]).unwrap();
let mut line = String::new();
BufReader::new(second).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ok"], json!(false));
assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}");
drop(first);
}
#[test]
fn the_budget_is_given_back_when_the_connection_ends() {
let budget = Budget::new(100);
{
let mut h = Held::new(budget.clone());
let mut buf = Vec::new();
let mut input: &[u8] = &[b'x'; 80];
assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true));
assert!(!budget.reserve(30), "80 of 100 bytes are held");
}
assert!(budget.reserve(100), "all given back on drop");
}
/// Replies on one authenticated connection preserve the ID of each request.
#[test]
fn requests_are_answered_with_their_ids() {
+1 -1
View File
@@ -218,7 +218,7 @@ fn main() -> eframe::Result {
let logger = logging::install();
// The update helper relaunches this binary after a self-update; it runs and exits here.
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("CADCraft update helper failed: {e}");
std::process::exit(1);
+8
View File
@@ -91,3 +91,11 @@ sync_github_name = false
real_name = "Robert Lee"
sync_github_name = true
[people.2ez4gcx]
real_name = "Doan Huynh Huu Khuong"
display_name = "Doan Huynh Huu Khuong"
sync_github_name = false
[people.dylanmtaylor]
sync_github_name = true
+4 -8
View File
@@ -52,15 +52,11 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {
g.curves.push((GeomRef::whole(h), ga.center, a.radius));
}
}
EntityKind::Circle(c) => {
if c.center.is_finite() && c.radius.is_finite() {
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
}
EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
}
EntityKind::Point(p) => {
if p.p.is_finite() {
g.points.push((GeomRef::whole(h), p.p.xy()));
}
EntityKind::Point(p) if p.p.is_finite() => {
g.points.push((GeomRef::whole(h), p.p.xy()));
}
EntityKind::LwPolyline(p) => {
let n = p.vertices.len();
+5 -7
View File
@@ -44,14 +44,16 @@ mod dim;
mod fill;
mod layout;
mod text;
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 dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
pub use fill::{Gradient, Hatch, HatchLoop, Wipeout};
pub use fill::{Gradient, Hatch, HatchLoop};
pub use layout::{Image, Table, TableCell, Viewport};
pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign};
pub use wipeout::Wipeout;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Unknown {
pub dxf_type: String,
@@ -226,11 +228,7 @@ impl EntityKind {
h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect()
}
EntityKind::Wipeout(w) => {
let mut pts = w.boundary.clone();
if let Some(f) = pts.first().copied() {
pts.push(f);
}
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
w.closed_boundary().windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
}
EntityKind::Viewport(v) => {
let c = v.center.xy();
@@ -535,7 +533,7 @@ impl EntityKind {
Bounds2::new(c - h, c + h).corners().to_vec()
}
EntityKind::Image(i) => vec![i.insert.xy()],
EntityKind::Wipeout(w) => w.boundary.clone(),
EntityKind::Wipeout(w) => w.vertices().to_vec(),
EntityKind::Table(t) => vec![t.insert.xy()],
EntityKind::Tolerance(t) => vec![t.insert.xy()],
EntityKind::Unknown(_) => Vec::new(),
+17
View File
@@ -130,3 +130,20 @@ pub struct RawTag {
pub code: i32,
pub value: String,
}
impl Common {
/// Whether "Match orientation to layout" is on for this annotative object (AutoCAD's
/// `AcadAnnoPO` xdata = 1). AutoCAD draws such text upright and ignores its stored rotation.
/// The object's own `AcadAnnotative` xdata never reaches [`Common::xdata`] (the reader drops
/// it), so the `AcadAnnoPO` flag alone decides.
pub fn matches_layout_orientation(&self) -> bool {
let app = self.xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("AcadAnnoPO"));
let Some(at) = app else { return false };
self.xdata
.iter()
.skip(at + 1)
.take_while(|t| t.code != 1001)
.find(|t| t.code == 1070)
.is_some_and(|t| t.value.trim().parse::<i64>().is_ok_and(|v| v != 0))
}
}
-5
View File
@@ -54,8 +54,3 @@ pub struct Gradient {
#[serde(default)]
pub centered: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Wipeout {
pub boundary: Vec<Vec2>,
}
+162
View File
@@ -0,0 +1,162 @@
//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
//! One exact terminal copy of the first point is a closure marker, not an editable corner.
//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
use std::borrow::Cow;
use cadcraft_geom::Vec2;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Wipeout {
/// Ordered boundary, implicitly closed or ending in a repeated first point.
pub boundary: Vec<Vec2>,
}
impl Wipeout {
/// Editable vertices, excluding one exactly repeated closing point, without changing storage.
pub fn vertices(&self) -> &[Vec2] {
match self.boundary.split_last() {
Some((last, vertices)) if vertices.first() == Some(last) => vertices,
_ => &self.boundary,
}
}
/// An explicitly closed outline for geometry, rendering and export. Already closed or
/// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
Cow::Borrowed(&self.boundary)
} else {
let mut points = self.boundary.clone();
points.extend(self.boundary.first());
Cow::Owned(points)
}
}
/// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
/// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
/// Invalid indices/non-finite points leave the boundary unchanged.
pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
let count = self.vertices().len();
if index >= count || !to.is_finite() {
return None;
}
let explicit = count < self.boundary.len();
*self.boundary.get_mut(index)? = to;
if explicit
&& index == 0
&& let Some(last) = self.boundary.last_mut()
{
*last = to;
}
if !explicit && self.vertices().len() < count {
// The moved endpoint is a logical corner, not the newly implied closing marker.
if let Some(first) = self.boundary.first().copied() {
self.boundary.push(first);
}
}
Some(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EntityKind;
#[test]
fn vertices_and_grips_exclude_only_the_exact_closing_point() {
let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
let near_a = Vec2::new(1e-13, 0.0);
for (boundary, expected) in [
(vec![], vec![]),
(vec![a], vec![a]),
(vec![a, a], vec![a]),
(vec![a, b, c], vec![a, b, c]),
(vec![a, b, c, a], vec![a, b, c]),
(vec![a, b, a, c, a], vec![a, b, a, c]),
(vec![a, b, c, a, a], vec![a, b, c, a]),
(vec![a, b, c, near_a], vec![a, b, c, near_a]),
] {
let wipeout = Wipeout { boundary: boundary.clone() };
assert_eq!(wipeout.vertices(), expected);
assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
assert_eq!(wipeout.boundary, boundary);
}
}
#[test]
fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
let implicit = Wipeout { boundary: points.clone() };
let mut expected = points.clone();
expected.push(points[0]);
assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
assert_eq!(implicit.closed_boundary().as_ref(), expected);
assert_eq!(implicit.boundary, points);
let explicit = Wipeout { boundary: expected.clone() };
assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
assert_eq!(explicit.closed_boundary().as_ref(), expected);
let a = EntityKind::Wipeout(implicit).prims();
let b = EntityKind::Wipeout(explicit).prims();
assert_eq!(a, b);
assert_eq!(a.len(), 3);
for boundary in [vec![], vec![Vec2::ZERO]] {
let wipeout = Wipeout { boundary: boundary.clone() };
assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
}
}
#[test]
fn moving_endpoints_together_keeps_both_corners_editable() {
let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
for explicit in [false, true] {
for index in [0, 3] {
let other = 3 - index;
let mut boundary = points.to_vec();
if explicit {
boundary.push(points[0]);
}
let mut wipeout = Wipeout { boundary };
let mut expected = points;
expected[index] = points[other];
for _ in 0..2 {
assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
assert_eq!(wipeout.vertices(), expected);
assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
}
expected[other] = Vec2::new(-2.0, 4.0);
assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
assert_eq!(wipeout.vertices(), expected);
assert_eq!(wipeout.boundary.len(), 5);
assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
}
}
}
#[test]
fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
for explicit in [false, true] {
let mut boundary = vec![a, b, a, c];
if explicit {
boundary.push(a);
}
let mut wipeout = Wipeout { boundary };
assert_eq!(wipeout.set_vertex(0, to), Some(()));
let mut expected = vec![to, b, a, c];
if explicit {
expected.push(to);
}
assert_eq!(wipeout.boundary, expected);
for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
assert_eq!(wipeout.set_vertex(index, point), None);
assert_eq!(wipeout.boundary, expected);
}
}
}
}
+78 -14
View File
@@ -36,11 +36,26 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
/// entities a visibility state hides are invisible this way, and they sit far from the block's
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]).
fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool {
// Attribute definitions are not drawn in an insert (its attribs are, see below).
!matches!(e.kind, EntityKind::AttDef(_))
&& e.common.visible
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
!matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts)
}
/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which
/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`.
fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool {
layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts))
}
/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its
/// definition's (layer "0" being the insert's).
fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str {
a.props.layer.as_deref().unwrap_or_else(|| {
d.block(&ins.block)
.and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))))
.map_or("0", |be| be.common.layer.as_str())
})
}
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
@@ -52,7 +67,9 @@ fn blank(s: &str) -> bool {
fn mtext_box(t: &crate::MText) -> Bounds2 {
let lines = t.contents.split("\\P").count().max(1);
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
let estimate = t.height * 0.8 * longest as f64;
// The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465).
let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate };
let mut h = t.height * 1.66 * lines as f64;
if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) {
// Columns side by side, the text flowing down each in turn.
@@ -75,16 +92,27 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
Bounds2::from_points(pts)
}
/// The box of what `e` draws (block contents on layers turned off left out).
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
bounds_of(d, e, depth, false)
}
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
bounds_of(d, e, 0, true)
}
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
// A top-level annotative object measures as it draws at the current annotation scale.
if depth == 0 {
match d.anno_draw(e) {
crate::AnnoDraw::Plain => {}
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()),
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
}
}
bounds_in(d, e, depth, &mut HashMap::new())
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
@@ -93,11 +121,16 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
/// definition per depth instead of one per reference (which grows exponentially with the nesting
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
/// bounds must not stand in for a shallower reference to the same block (#706).
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 {
match &e.kind {
EntityKind::Table(t) if t.is_broken() => t.bounds(),
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
// Text that matches the layout's orientation is drawn upright (#2498).
EntityKind::Text(t) if e.common.matches_layout_orientation() => {
text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count())
}
EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }),
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
EntityKind::MText(t) => mtext_box(t),
@@ -115,8 +148,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
@@ -131,7 +164,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
}
}
for a in &ins.attribs {
if !a.invisible {
if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) {
b = b.union(&match a.multiline_text() {
Some(m) => mtext_box(&m),
None => text_box(&a.text, a.text.value.chars().count()),
@@ -172,8 +205,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
Some(cached) => *cached,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
@@ -193,7 +226,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
}
if let Some(ins) = &m.block {
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
b = b.union(&bounds_in(d, &be, depth, blocks));
b = b.union(&bounds_in(d, &be, depth, blocks, off_counts));
}
b
}
@@ -309,6 +342,37 @@ mod tests {
entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0)
}
fn mtext(contents: &str, width: f64) -> crate::MText {
crate::MText {
insert: Vec3::new(10.0, 10.0, 0.0),
height: 2.5,
width,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: contents.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}
}
/// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not
/// stretch the drawing extents (#2465).
#[test]
fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() {
let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0)));
// "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide.
assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x);
assert!(b.min.x >= 10.0 - 1e-9);
}
/// A wrap width narrower than the text still bounds the box to that width.
#[test]
fn a_narrow_mtext_wrap_width_still_sets_the_box_width() {
let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0)));
assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x);
}
#[test]
fn right_justified_text_is_left_of_alignment_point() {
let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline)));
+10 -4
View File
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
@@ -369,13 +369,19 @@ impl Drawing {
!self.hidden_objects.is_empty() && self.hidden_objects.contains(&h)
}
/// Bounds of all visible entities in a space.
/// Whether an entity counts in the extents: as AutoCAD 2024 counts it for ZOOM Extents and
/// EXTMIN/EXTMAX, on a layer that is off too, but not on a frozen one.
pub fn in_extents(&self, e: &Entity) -> bool {
e.common.visible && self.layer(&e.common.layer).is_none_or(|l| !l.frozen) && !self.is_hidden_object(e.handle)
}
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
let mut b = cadcraft_geom::Bounds2::EMPTY;
if let Some(store) = self.space(space) {
for e in store.iter() {
if self.is_visible(e) {
b = b.union(&entity_bounds(self, e, 0));
if self.in_extents(e) {
b = b.union(&extent_bounds(self, e));
}
}
}
+110 -21
View File
@@ -44,15 +44,85 @@ fn add_and_extents() {
}
#[test]
fn hidden_layers_excluded_from_extents() {
fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
let mut d = Drawing::new_imperial();
let c = Common { layer: "Hidden".into(), ..Default::default() };
d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap();
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
d.layer_mut("Hidden").unwrap().on = false;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(200.0, 200.0));
d.layer_mut("Hidden").unwrap().frozen = true;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
}
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
/// entity inside a block, and for a block reference itself.
#[test]
fn extents_count_layers_turned_off_as_autocad() {
let mut d = Drawing::new_imperial();
for (name, on, frozen) in [("OFFL", false, false), ("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
let mut l = Layer::new(name);
(l.on, l.frozen) = (on, frozen);
d.layers.push(l);
}
for (layer, b) in [("0", (10.0, 10.0)), ("OFFL", (100.0, 0.0)), ("FRZ", (0.0, 200.0))] {
d.add(&Space::Model, Common { layer: layer.into(), ..Default::default() }, line((0.0, 0.0), b)).unwrap();
}
// AutoCAD: (-0.013, -0.009)…(100.027, 10.006).
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(100.0, 10.0)));
let mut d = Drawing::new_imperial();
for (name, on, frozen) in [("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
let mut l = Layer::new(name);
(l.on, l.frozen) = (on, frozen);
d.layers.push(l);
}
let mut blk = Block::new("BX");
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
blk.entities.push(on_layer("BOFF", line((0.0, 0.0), (50.0, 0.0)), 2));
blk.entities.push(on_layer("BFRZ", line((0.0, 0.0), (0.0, 70.0)), 3));
d.blocks.insert("BX".into(), std::sync::Arc::new(blk));
let at = |x: f64| match plain_insert("BX") {
EntityKind::Insert(i) => EntityKind::Insert(Insert { insert: Vec3::new(x, x, 0.0), ..i }),
k => k,
};
d.add(&Space::Model, Common::default(), at(1000.0)).unwrap();
d.add(&Space::Model, Common { layer: "FRZ".into(), ..Default::default() }, at(-500.0)).unwrap();
// AutoCAD: (1000, 1000)…(1050, 1001).
assert_eq!(extents_pair(&d), (Vec2::new(1000.0, 1000.0), Vec2::new(1050.0, 1001.0)));
// An insert's attributes lie on their own layer, else on their definition's: one on a frozen
// layer either way does not count, one on a layer turned off does.
let mut d = Drawing::new_imperial();
for (name, on, frozen) in [("IOFF", false, false), ("AOFF", false, false), ("AFRZ", true, true)] {
let mut l = Layer::new(name);
(l.on, l.frozen) = (on, frozen);
d.layers.push(l);
}
let attrib = |tag: &str, at: (f64, f64), layer: Option<&str>| {
let mut a = plain_attrib(tag);
a.text.insert = Vec3::new(at.0, at.1, 0.0);
a.props.layer = layer.map(str::to_string);
a
};
let mut blk = Block::new("BA");
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
blk.entities.push(on_layer("AFRZ", EntityKind::AttDef(plain_attrib("DEF")), 2));
d.blocks.insert("BA".into(), std::sync::Arc::new(blk));
let ins = match plain_insert("BA") {
EntityKind::Insert(i) => {
let attribs =
vec![attrib("OWN", (0.0, 300.0), Some("AFRZ")), attrib("DEF", (0.0, -400.0), None), attrib("OFF", (40.0, 0.0), Some("AOFF"))];
EntityKind::Insert(Insert { attribs, ..i })
}
k => k,
};
d.add(&Space::Model, Common { layer: "IOFF".into(), ..Default::default() }, ins).unwrap();
let (min, max) = extents_pair(&d);
assert!(min.y > -1.0 && max.y < 50.0 && max.x > 40.0, "{min:?} {max:?}");
}
#[test]
fn rays_and_xlines_do_not_stretch_extents() {
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
@@ -315,6 +385,31 @@ fn plain_insert(block: &str) -> EntityKind {
})
}
/// An attribute (or its definition) `tag` at the origin, one unit high, reading "x".
fn plain_attrib(tag: &str) -> Attrib {
Attrib {
tag: tag.into(),
text: Text {
insert: Vec3::ZERO,
align_pt: None,
height: 1.0,
value: "x".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
},
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
}
}
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
}
@@ -383,24 +478,18 @@ fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
}
#[test]
fn block_entity_on_off_or_frozen_layer_is_ignored() {
for frozen in [false, true] {
let mut d = drawing_with_block(far_line("Hidden"));
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
let mut layer = Layer::new("Hidden");
if frozen {
layer.frozen = true;
} else {
layer.on = false;
}
d.layers.push(layer);
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
// Turned back on, it counts again.
let l = d.layer_mut("Hidden").unwrap();
l.on = true;
l.frozen = false;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
fn block_entity_on_frozen_layer_is_ignored_and_on_a_layer_turned_off_counts() {
let mut d = drawing_with_block(far_line("Hidden"));
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
let mut layer = Layer::new("Hidden");
layer.frozen = true;
d.layers.push(layer);
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
// Thawed but turned off, it counts.
let l = d.layer_mut("Hidden").unwrap();
l.frozen = false;
l.on = false;
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
@@ -558,10 +647,10 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
hidden.common.visible = false;
dim(&mut d, hidden);
assert!(d.extents(&Space::Model).max.x < 100.0);
// Hidden by its layer state.
// Hidden by its layer state (frozen; a layer turned off still counts).
let mut d = Drawing::new_imperial();
let mut l = Layer::new("*ADSK_CONSTRAINTS");
l.on = false;
l.frozen = true;
d.layers.push(l);
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
assert!(d.extents(&Space::Model).max.x < 100.0);
+4 -1
View File
@@ -8,6 +8,9 @@ license.workspace = true
[lints]
workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
# 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"
+12
View File
@@ -0,0 +1,12 @@
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
//! there return an error that says to use DXF.
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
println!("cargo::rustc-cfg=no_dwg_bridge");
}
}
+196 -31
View File
@@ -58,13 +58,13 @@ impl Limits {
}
/// A byte count in megabytes for messages ("9.9 MB", "1024 MB").
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
#[cfg_attr(no_dwg_bridge, allow(dead_code))]
fn mb(n: u64) -> String {
let m = n as f64 / f64::from(1u32 << 20);
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
mod native {
use std::io::{Cursor, Write};
use std::panic::AssertUnwindSafe;
@@ -104,10 +104,7 @@ mod native {
}
let mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
current_style_names(&mut doc);
table_merges(&mut doc);
table_style_rows(&mut doc, false);
let orphans = drop_orphaned_dgn_components(&mut doc);
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
@@ -237,6 +234,33 @@ mod native {
/// cells already carry merges are left alone. Each range's first cell gets the span; the
/// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does.
fn table_merges(doc: &mut acadrust::CadDocument) {
table_merges_within(doc, MAX_MERGE_CELLS);
}
/// The most table cells [`table_merges`] visits in one document: each range is capped, but
/// can cover rows × cols cells, so the total is bounded too.
const MAX_MERGE_CELLS: usize = 1 << 24;
/// 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_style_rows(doc, false);
keep_sort_handles(doc);
drop_orphaned_dgn_components(doc)
}
/// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way).
fn guarded<R>(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result<R, ()> {
std::panic::catch_unwind(f).map_err(|_| ())
}
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
/// left unmerged. Returns the cells visited.
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
let mut left = budget;
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) {
@@ -252,6 +276,11 @@ mod native {
if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) {
continue;
}
let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1);
if area > left {
continue;
}
left -= area;
let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX);
for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) {
for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) {
@@ -263,6 +292,28 @@ mod native {
}
}
}
budget - left
}
/// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF
/// writer renumbers them past every entity handle, but an entity left out of a table sorts by
/// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving
/// the table its codes verbatim makes the writer emit them unchanged.
fn keep_sort_handles(doc: &mut acadrust::CadDocument) {
use acadrust::objects::ObjectType;
let version = doc.version;
for o in doc.objects.values_mut() {
let ObjectType::SortEntitiesTable(t) = o else { continue };
let hex = |h: acadrust::types::Handle| format!("{:X}", h.value());
let mut codes =
vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))];
for e in t.entries() {
codes.push((331, hex(e.entity_handle)));
codes.push((5, hex(e.sort_handle)));
}
t.raw_dxf_codes = Some(codes);
t.raw_dxf_version = Some(version);
}
}
/// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps
@@ -512,6 +563,17 @@ mod native {
}
}
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
/// the document, so the drawing reopened with layer "0" current (#2252).
fn current_layer(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_layer_name.clone();
// The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä").
if let Some(layer) = doc.layers.get(&name) {
doc.header.current_layer_handle = layer.handle;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
@@ -536,13 +598,19 @@ mod native {
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
None => {}
}
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
current_layer(&mut doc);
}))
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
@@ -639,43 +707,140 @@ mod native {
}
}
}
#[cfg(test)]
mod post_tests {
use super::*;
use acadrust::entities::{CellRange, EntityType, Table};
fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument {
let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols);
t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect();
let mut doc = acadrust::CadDocument::new();
assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok());
doc
}
/// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934).
#[test]
fn merge_work_is_bounded_by_the_cell_budget() {
let mut doc = doc_with_table(10, 10, 50);
assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped");
let mut doc = doc_with_table(10, 10, 1);
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
}
/// 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() {
#[allow(clippy::panic)]
let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") }));
assert!(r.is_err());
assert_eq!(guarded(|| 3), Ok(3));
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
// stage then panics on entry, so a test through the public API shows whether it is guarded.
#[cfg(all(test, not(no_dwg_bridge)))]
thread_local! {
static PANIC_IN: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise.
#[cfg(not(no_dwg_bridge))]
#[allow(clippy::panic)]
fn fail_in_test(_stage: &str) {
#[cfg(test)]
if PANIC_IN.with(|p| p.get()) == Some(_stage) {
panic!("test-forced panic in {_stage}");
}
}
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
#[cfg_attr(no_dwg_bridge, allow(dead_code))]
fn unsupported(version: &str) -> String {
let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect();
format!("DWG version `{version}` can't be written (CADCraft writes {})", list.join(", "))
}
#[cfg(target_arch = "wasm32")]
/// Which build has no DWG bridge, for the stubs' errors.
#[cfg(no_dwg_bridge)]
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(target_arch = "wasm32")]
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(target_arch = "wasm32")]
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(target_arch = "wasm32")]
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be written in the web build yet; save as DXF".into())
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(target_arch = "wasm32")]
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
Err("DWG files can't be written in the web build yet; save as DXF".into())
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(test)]
mod tests {
use super::*;
/// The DXF of an empty drawing, enough for the bridge to make a small DWG.
#[cfg(not(no_dwg_bridge))]
const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n";
/// Makes `stage` panic on the current thread until dropped.
#[cfg(not(no_dwg_bridge))]
struct PanicIn;
#[cfg(not(no_dwg_bridge))]
impl PanicIn {
fn at(stage: &'static str) -> Self {
PANIC_IN.with(|p| p.set(Some(stage)));
Self
}
}
#[cfg(not(no_dwg_bridge))]
impl Drop for PanicIn {
fn drop(&mut self) {
PANIC_IN.with(|p| p.set(None));
}
}
/// The public read reports a panic in the post-read fix-ups as an error: this fails if the
/// `guarded` call around `post_process` is taken out (#2402).
#[cfg(not(no_dwg_bridge))]
#[test]
fn reading_a_dwg_survives_a_panic_in_post_processing() {
let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap();
assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic");
let _panic = PanicIn::at("post_process");
let err = dwg_to_dxf(&dwg).unwrap_err();
assert!(err.contains("the DWG post-processing failed on this file"), "{err}");
}
/// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402).
#[cfg(not(no_dwg_bridge))]
#[test]
fn converting_to_dwg_survives_a_panic_in_the_fix_ups() {
let _panic = PanicIn::at("convert");
let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err();
assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}");
}
#[test]
fn detects_magic() {
assert!(is_dwg(b"AC1032\0\0\0\0"));
@@ -685,7 +850,7 @@ mod tests {
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[test]
fn current_text_style_reaches_the_dwg() {
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
@@ -705,7 +870,7 @@ mod tests {
assert!(dwg_to_dxf(b"").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
@@ -715,7 +880,7 @@ mod tests {
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
for (text, flags, sub, pts) in dims {
@@ -732,7 +897,7 @@ mod tests {
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
/// as the writer can't hide a wrong byte.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[test]
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
@@ -779,7 +944,7 @@ mod tests {
}
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
let lines: Vec<&str> = dxf.lines().collect();
lines
@@ -796,7 +961,7 @@ mod tests {
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
/// their dictionary entries with them; the definition and every component it reaches —
/// through compound and point components, around a cycle — are kept.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[test]
fn orphaned_dgn_line_style_components_are_left_out() {
use acadrust::objects::*;
+36
View File
@@ -69,6 +69,42 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
decoder(code_page).map(|d| decode(bytes, d).into_owned())
}
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
/// reader expects.
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
if code_page.trim().eq_ignore_ascii_case("ASCII") {
return None;
}
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
}
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
let mut buf = [0; 4];
match d {
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
Decoder::Utf8 => return false,
Decoder::Encoding(e) => {
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
// A code page byte that is ASCII would read back as another character, and a NUL,
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
return false;
}
out.extend_from_slice(&bytes);
}
Decoder::Table(t) => {
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
out.push(0x80 | i);
}
}
true
}
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
match d {
+106 -24
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
use std::fmt::Write as _;
use std::io::Write as _;
mod version;
pub use version::Version;
@@ -181,8 +182,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
return parse_binary_with_limit(body, max_tags);
}
// Every group code takes two lines (code, value).
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
let mut lines = bytes.split(|&b| b == b'\n');
let mut tags = 0;
while let Some(code) = lines.next() {
let code = code.trim_ascii();
if code.is_empty() {
continue;
}
let Some(value) = lines.next() else { break };
tags += 1;
if code == b"0" && value.trim_ascii() == b"EOF" {
break;
}
}
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
@@ -264,7 +277,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
#[allow(clippy::while_let_loop)]
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
let mut lines = text.lines().enumerate();
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
let ct = code_line.trim();
@@ -409,17 +422,48 @@ pub fn write_ascii(tags: &[Tag]) -> String {
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
/// breaks inside strings become spaces either way.
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
let mut out = String::with_capacity(tags.len() * 16);
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
// Both forms are UTF-8 (the escaped one is ASCII).
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
/// DXF that is valid UTF-8 as UTF-8.
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
return ascii(tags, Strings::Escaped(None));
}
bytes
}
/// How a writer stores string values.
#[derive(Clone, Copy)]
enum Strings {
/// As they are (R2007+).
Utf8,
/// ASCII as it is, other characters in this code page when it has them, otherwise as
/// `\U+XXXX` escapes (before R2007).
Escaped(Option<codepage::Decoder>),
}
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(tags.len() * 16);
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) => escape_unicode(s, utf8, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
Value::Hex(h) => out.push_str(h),
Value::Str(s) => push_string(s, strings, &mut out),
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
Value::Hex(h) => out.extend_from_slice(h.as_bytes()),
}
out.push_str("\r\n");
out.extend_from_slice(b"\r\n");
}
out
}
@@ -432,6 +476,16 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
/// is stored as a number; a code outside the 16-bit range is skipped.
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
}
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
/// [`write_ascii_in`] does.
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
}
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
out.extend_from_slice(BINARY_SENTINEL);
for t in tags {
@@ -444,9 +498,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
Value::Str(s) => s.clone(),
v => v.as_str(),
};
let mut text = String::with_capacity(s.len());
escape_unicode(&s, utf8, &mut text);
out.extend(text.bytes().filter(|&b| b != 0));
let mut text = Vec::with_capacity(s.len());
push_string(&s, strings, &mut text);
out.extend(text.into_iter().filter(|&b| b != 0));
out.push(0);
}
Kind::Real => {
@@ -500,19 +554,27 @@ pub fn is_binary(bytes: &[u8]) -> bool {
bytes.starts_with(BINARY_SENTINEL)
}
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
/// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
let chars: Vec<char> = s.chars().collect();
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
for (i, &c) in chars.iter().enumerate() {
match c {
'\r' | '\n' => out.push(' '),
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c => {
match (c, strings) {
('\r' | '\n', _) => out.push(b' '),
('\\', _) if escape_at(i + 1) => {
out.extend_from_slice(b"\\U+005C");
}
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
(c, Strings::Escaped(_)) => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
let _ = write!(out, "\\U+{u:04X}");
@@ -522,6 +584,20 @@ fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
}
}
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
let start = out.len();
if !codepage::encode_char(c, d, out) {
return false;
}
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
out.truncate(start);
return false;
}
true
}
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
#[derive(Clone, Debug, Default)]
pub struct Section {
@@ -581,6 +657,12 @@ mod tests {
}
}
#[test]
fn blank_lines_do_not_consume_group_code_limit() {
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
}
#[test]
fn ascii_roundtrip() {
let tags = vec![
+19
View File
@@ -0,0 +1,19 @@
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
#[test]
fn utf8_bom_bytes_are_skipped() {
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(BODY.as_bytes());
let with_bom = cadcraft_dxf::parse(&bytes);
assert!(with_bom.is_ok(), "{with_bom:?}");
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
}
#[test]
fn bom_text_is_skipped() {
let text = format!("\u{feff}{BODY}");
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
}
+116
View File
@@ -0,0 +1,116 @@
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
fn file(code_page: &str, text: &str) -> Vec<Tag> {
vec![
Tag::s(0, "SECTION"),
Tag::s(2, "HEADER"),
Tag::s(9, "$ACADVER"),
Tag::s(1, "AC1015"),
Tag::s(9, "$DWGCODEPAGE"),
Tag::s(3, code_page),
Tag::s(0, "ENDSEC"),
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "TEXT"),
Tag::s(1, text),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
]
}
fn contains(bytes: &[u8], part: &[u8]) -> bool {
bytes.windows(part.len()).any(|w| w == part)
}
#[test]
fn text_is_written_in_the_code_page() {
// (code page, text, the bytes written for it)
let cases: [(&str, &str, &[u8]); 6] = [
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
];
for (cp, text, raw) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
#[test]
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
let tags = file(cp, "Ação\r\nfim");
let bytes = write_ascii_in(&tags, cp);
assert!(bytes.is_ascii(), "{cp}");
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
}
}
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
#[test]
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
let tags = file("ANSI_1251", "СІ");
let ascii = write_ascii_in(&tags, "ANSI_1251");
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
let binary = write_binary_in(&tags, "ANSI_1251");
assert!(contains(&binary, b"\xD1\xB2"));
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
// Bytes that are not valid UTF-8 stay as they are.
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
}
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
/// would read as UTF-8.
#[test]
fn literal_escapes_survive_code_page_text() {
for (cp, text) in
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
{
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
#[test]
fn a_trail_byte_backslash_never_starts_an_escape() {
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
];
for (cp, text, written, never) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
//! Handwritten examples, not files from a CAD application or generated by our writer.
//! Background on the tag model and file structure:
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
vec![
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
]
}
#[test]
fn reads_independently_authored_ascii_and_binary_values() {
for (expected, ascii, binary) in fixtures() {
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
let bytes = [SENTINEL, binary].concat();
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
}
}
#[test]
fn writes_independently_specified_ascii_and_binary_bytes() {
for (tag, ascii, binary) in fixtures() {
for utf8 in [false, true] {
let tags = std::slice::from_ref(&tag);
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
}
}
}
#[test]
fn integer_code_ranges_have_independent_binary_widths() {
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
// can round-trip every boundary value while still using the wrong number of bytes.
let widths = [
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
];
for (ranges, width, min, max) in widths {
for code in ranges.into_iter().flatten() {
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
// little-endian two's-complement integer. Encoding is version-independent.
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
let value_bytes = value.to_le_bytes();
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
let tags = [Tag::i(code, value)];
for utf8 in [false, true] {
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
}
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
}
}
}
}
#[test]
fn text_encoding_has_independent_wire_expectations() {
let tags = [Tag::s(1, "é😀")];
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
assert_eq!(parse(&ascii).unwrap(), tags);
assert_eq!(parse(&binary).unwrap(), tags);
}
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
let binary = [
SENTINEL,
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
]
.concat();
for bytes in [ascii.as_slice(), binary.as_slice()] {
let tags = parse(bytes).unwrap();
assert_eq!(tags[7], Tag::s(1, "ێ"));
}
}
#[test]
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
for newline in ["\n", "\r\n"] {
for final_newline in [false, true] {
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
if final_newline {
text.push_str(newline);
}
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
}
}
}
#[test]
fn malformed_and_truncated_values_are_errors_not_panics() {
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
for (_, _, binary) in fixtures() {
// Every cut inside the first record is an error, unlike cuts between complete records:
// the tag API deliberately accepts streams without an EOF record.
for cut in 1..binary.len() {
let bytes = [SENTINEL, &binary[..cut]].concat();
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
}
}
}
#[test]
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
for bytes in [ascii.as_slice(), binary.as_slice()] {
for limit in 0..3 {
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
}
for limit in [3, 4] {
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
//! Pending writer regressions extend the chunk-size finding in issue #1025.
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
fn payload(length: usize) -> Vec<u8> {
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
}
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
}
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
let wire = write_ascii_as(&input(code, bytes), utf8);
let lines: Vec<_> = wire.lines().collect();
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
assert_eq!(following[0].trim(), "70");
assert_eq!(following[1], "-7");
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
let (records, remainder) = chunks.as_chunks::<2>();
assert!(remainder.is_empty(), "incomplete group/value pair");
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
let mut back = Vec::new();
for record in records {
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
let text = record[1];
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
assert!(remainder.is_empty(), "{context}: partial byte");
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
}
assert_eq!(back, bytes, "{context}: complete payload and byte order");
}
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
let wire = write_binary(&input(code, bytes), utf8);
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
let mut back = Vec::new();
while !chunks.is_empty() {
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
let (chunk, next) = rest.split_at(usize::from(length));
back.extend_from_slice(chunk);
chunks = next;
}
assert_eq!(back, bytes, "{context}: complete payload and byte order");
}
#[test]
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
for code in (310..=319).chain([1004]) {
for length in [0, 1, 2, 126, 127] {
for utf8 in [false, true] {
let bytes = payload(length);
check_ascii(code, &bytes, utf8);
check_binary(code, &bytes, utf8);
}
}
}
}
#[test]
fn binary_reader_tolerates_the_full_length_byte_domain() {
// This is deliberately permissive input, not an assertion that oversized chunks conform
// or that the writer should produce them. The following record catches framing mistakes.
for code in (310_u16..=319).chain([1004]) {
for length in 0..=u8::MAX {
let bytes = payload(usize::from(length));
let mut wire = SENTINEL.to_vec();
wire.extend_from_slice(&code.to_le_bytes());
wire.push(length);
wire.extend_from_slice(&bytes);
wire.extend_from_slice(FOLLOWING_BINARY);
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
}
}
}
#[test]
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
for code in (310..=319).chain([1004]) {
for length in [128, 254, 255, 256, 1024] {
for utf8 in [false, true] {
check_ascii(code, &payload(length), utf8);
}
}
}
}
#[test]
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
for code in (310..=319).chain([1004]) {
for length in [128, 254, 255, 256, 1024] {
for utf8 in [false, true] {
check_binary(code, &payload(length), utf8);
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
//! Deterministic tag preservation through the public byte API, in both encodings and every
//! supported version. Inputs have the group code's type and representable values; lossy
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
fn check(case: &str, body: &[Tag]) {
for version in Version::ALL {
let mut original = vec![
Tag::s(0, "SECTION"),
Tag::s(2, "HEADER"),
Tag::s(9, "$ACADVER"),
Tag::s(1, version.code()),
Tag::s(9, "$DWGCODEPAGE"),
Tag::s(3, "ANSI_1252"),
Tag::s(0, "ENDSEC"),
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
];
original.extend_from_slice(body);
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
for binary in [false, true] {
let mut current = original.clone();
for pass in 1..=2 {
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
let bytes = if binary { write_binary(&current, version.utf8()) } else { write_ascii_as(&current, version.utf8()).into_bytes() };
assert_eq!(is_binary(&bytes), binary, "{context}");
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
assert_eq!(back.len(), original.len(), "{context}: tag count");
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
assert_eq!(actual, expected, "{context}: tag[{index}]");
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
}
}
assert_eq!(back, current, "{context}: no additional changes on resave");
current = back;
}
}
}
}
#[test]
fn integer_width_boundaries() {
// Explicit reference ranges, not obtained from the codec's kind_of().
let widths = [
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
];
for (ranges, min, max) in widths {
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
check(&format!("integer/{min}..{max}/{value}"), &tags);
}
}
}
#[test]
fn finite_reals_keep_all_bits() {
let values = [
0.0,
-0.0,
f64::from_bits(1),
-f64::from_bits(1),
f64::MIN_POSITIVE,
f64::MIN_POSITIVE.next_down(),
0.1,
-3.25,
1.0_f64.next_down(),
1.0,
1.0_f64.next_up(),
1e-100,
-1e100,
f64::MAX,
f64::MIN,
];
for value in values {
let tags: Vec<_> =
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
check(&format!("real/{:016X}", value.to_bits()), &tags);
}
}
#[test]
fn booleans() {
for value in [false, true] {
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
check(&format!("bool/{value}"), &tags);
}
}
#[test]
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
let values = [
("empty", ""),
("spaces", " leading and trailing "),
("tabs", "\tindented\t"),
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
];
for (case, value) in values {
let tags: Vec<_> =
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
.into_iter()
.flatten()
.map(|code| Tag::s(code, value))
.collect();
check(case, &tags);
}
for length in [1, 254, 255, 256, 1024] {
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
}
}
#[test]
fn hex_chunks_keep_bytes_and_order() {
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
// byte can represent more, but that is reader tolerance, not writer conformance.
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
for length in [0, 1, 2, 126, 127] {
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
check(&format!("hex/bytes={length}"), &tags);
}
}
#[test]
fn mixed_values_and_repeated_codes_keep_order() {
check(
"mixed/repeated-codes",
&[
Tag::s(0, "TEST"),
Tag::f(10, 1.25),
Tag::s(1, "first"),
Tag::f(10, -2.5),
Tag::i(70, -7),
Tag::s(1, "second"),
Tag::new(290, Value::Bool(true)),
Tag::new(310, Value::Hex("00FF80".into())),
Tag::i(160, i64::MAX),
Tag::s(1, ""),
],
);
}
+117 -1
View File
@@ -30,19 +30,29 @@ const MAX_TRIES: u32 = 32;
/// are written through the retained handle and flushed, and the temporary is then renamed over
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
// A symlinked destination is resolved so the link stays a link and its target is replaced.
let resolved = resolve_symlink(path)?;
let path = resolved.as_deref().unwrap_or(path);
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
write_atomic_with(path, permissions, |file| file.write_all(bytes))
}
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.write_all(bytes)?;
fill(&mut file)?;
file.flush()?;
file.sync_all()
})();
@@ -55,9 +65,57 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
sync_dir(&dir);
Ok(())
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
fn sync_dir(dir: &Path) {
#[cfg(unix)]
if let Ok(d) = File::open(dir) {
let _ = d.sync_all();
}
#[cfg(not(unix))]
let _ = dir;
}
/// Resolve `path` when it is a symlink, or return `None` if it is not one.
///
/// A dangling link (for example into an unavailable mount) cannot be canonicalized, so its target
/// is followed by hand with `read_link`, joining relative targets onto the link's directory.
fn resolve_symlink(path: &Path) -> io::Result<Option<PathBuf>> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {}
_ => return Ok(None),
}
if let Ok(target) = std::fs::canonicalize(path) {
return Ok(Some(target));
}
let mut current = path.to_path_buf();
for _ in 0..40 {
let target = std::fs::read_link(&current)?;
current = if target.is_absolute() { target } else { current.parent().map_or(target.clone(), |p| p.join(&target)) };
match std::fs::symlink_metadata(&current) {
Ok(meta) if meta.file_type().is_symlink() => {}
Ok(_) => return finish_target(&current),
Err(e) if e.kind() == io::ErrorKind::NotFound => return finish_target(&current),
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::InvalidInput, "too many levels of symbolic links"))
}
/// Build the write path for a resolved, non-link (possibly missing) target.
fn finish_target(current: &Path) -> io::Result<Option<PathBuf>> {
// The target's directory must exist; otherwise report the error rather than replacing the link.
let parent = current.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let parent = std::fs::canonicalize(parent)?;
let name = current.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "symlink target has no file name"))?;
Ok(Some(parent.join(name)))
}
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
///
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
@@ -116,6 +174,18 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// A write into a folder whose entries can be listed syncs that folder without failing, and
/// a folder that can't be opened (gone) is no error for the sync itself.
#[test]
fn the_folder_is_synced_after_the_rename() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sync-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
write_atomic(&dir.join("a.bin"), b"x").unwrap();
assert_eq!(std::fs::read(dir.join("a.bin")).unwrap(), b"x");
sync_dir(&dir.join("missing"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
@@ -147,6 +217,52 @@ mod tests {
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn symlinked_destination_updates_the_target() {
use std::os::unix::fs::{PermissionsExt, symlink};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-link-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let real = dir.join("real.dxf");
let link = dir.join("link.dxf");
std::fs::write(&real, b"old").unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
symlink(&real, &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file");
assert_eq!(std::fs::read(&real).unwrap(), b"new");
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn dangling_symlink_destination_creates_the_target() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-dangle-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(dir.join("mnt")).unwrap();
let real = dir.join("mnt").join("real.dxf");
let link = dir.join("link.dxf");
symlink("mnt/real.dxf", &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file");
assert_eq!(std::fs::read(&real).unwrap(), b"new");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn dangling_symlink_with_missing_parent_errors_and_keeps_the_link() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomnt-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("link.dxf");
symlink("missing/real.dxf", &link).unwrap();
assert!(write_atomic(&link, b"new").is_err());
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn private_temp_is_restricted_from_creation() {
+6 -10
View File
@@ -460,17 +460,13 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
m.style = "Standard".into();
}
}
EntityKind::Table(t) => {
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
note("table style", &t.style);
t.style = "Standard".into();
}
EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => {
note("table style", &t.style);
t.style = "Standard".into();
}
EntityKind::Leader(l) => {
if !ok_dim(d, &l.style) {
note("dimension style", &l.style);
l.style = "Standard".into();
}
EntityKind::Leader(l) if !ok_dim(d, &l.style) => {
note("dimension style", &l.style);
l.style = "Standard".into();
}
EntityKind::Dimension(dim) => {
if !ok_dim(d, &dim.style) {
+43 -2
View File
@@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60;
const HEARTBEAT_SECS: f64 = 60.0;
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// ISAVEBAK: whether saving over a drawing keeps the previous file as `<name>.bak` (on by
/// default, as in AutoCAD). A profile setting like SAVETIME.
pub fn isavebak() -> bool {
ISAVEBAK.load(Ordering::Relaxed)
}
/// Turn ISAVEBAK on or off.
pub fn set_isavebak(on: bool) {
ISAVEBAK.store(on, Ordering::Relaxed);
}
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
@@ -59,10 +71,22 @@ pub fn set_savefilepath(path: &str) {
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect();
// File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary.
let suffix = format!("_{instance}_{uid}.{EXT}");
let budget = 255usize.saturating_sub(suffix.len());
let mut used = 0;
let stem: String = title
.chars()
.map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' })
.take(80)
.take_while(|c| {
used += c.len_utf8();
used <= budget
})
.collect();
let stem = stem.trim().trim_matches('.');
let stem = if stem.is_empty() { "Drawing" } else { stem };
format!("{stem}_{instance}_{uid}.{EXT}")
format!("{stem}{suffix}")
}
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
@@ -478,3 +502,20 @@ mod native {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_fits_255_bytes_for_multibyte_titles() {
let instance = "12345-abcdef0123456789";
for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] {
let name = file_name(&title, instance, u64::MAX);
assert!(name.len() <= 255, "{} bytes", name.len());
let (t, i, u) = parse_file_name(&name).expect("round trips");
assert_eq!((i, u), (instance, u64::MAX));
assert!(!t.is_empty());
}
}
}
+1
View File
@@ -33,6 +33,7 @@ pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
pub(crate) use props::UNIT_RANGES;
pub(crate) use props::{current_color_value, set_current_color_value};
mod qselect;
mod selecttools;
+14 -1
View File
@@ -48,6 +48,19 @@ pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
{
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
}
// The measurement is what the DXF/DWG writer computes from the geometry (group 42); filling it
// in now means queries show it before the drawing is saved and reopened (#2421).
if let EntityKind::Dimension(dm) = &mut k
&& let Ok(d) = s.doc()
{
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0));
dm.measurement = g.measurement;
// The text position the writer puts in group 11 when the user has not placed the text.
if !dm.user_text_pos {
dm.text_mid = g.text_pos.to3(0.0);
}
}
let h = s.add_entity(k)?;
s.last_dim = Some(h);
Ok(h)
@@ -94,7 +107,7 @@ pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
s.pick_object(&s.space(), p, ap).ok().flatten()
}
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
+12 -9
View File
@@ -328,16 +328,19 @@ fn run_array(s: &mut Session, p: &Value) -> Result<Value> {
(spec.run)(s, p)
}
/// A count parameter (at least 1).
fn count_or(p: &Value, key: &str, default: u64) -> u64 {
p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1)
/// A count parameter (at least 1); a missing key gives the default, an invalid value is refused.
fn count_or(cmd: &str, p: &Value, key: &str, default: u64) -> Result<u64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_u64().filter(|n| *n >= 1).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number of at least 1"))),
}
}
fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let rows = count_or(p, "rows", 3);
let cols = count_or(p, "cols", 4);
let levels = count_or(p, "levels", 1);
let rows = count_or("arrayrect", p, "rows", 3)?;
let cols = count_or("arrayrect", p, "cols", 4)?;
let levels = count_or("arrayrect", p, "levels", 1)?;
check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?;
let kind = ArrayKind::Rect(RectArray {
cols: count32(cols),
@@ -360,9 +363,9 @@ fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let c = point_req("arraypolar", p, "center")?;
let n = count_or(p, "count", 6);
let rows = count_or(p, "rows", 1);
let levels = count_or(p, "levels", 1);
let n = count_or("arraypolar", p, "count", 6)?;
let rows = count_or("arraypolar", p, "rows", 1)?;
let levels = count_or("arraypolar", p, "levels", 1)?;
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);
+105 -16
View File
@@ -202,13 +202,13 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation
insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values)
}
/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block
/// The block reference INSERT places: X/Y/Z scale factors, and an optional base point (in block
/// coordinates) that lands on `at` instead of the block's own base point.
fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option<Vec2>) -> Insert {
fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option<Vec2>) -> Insert {
let mut ins = Insert {
block: blk.name.clone(),
insert: v3(at),
scale: Vec3::new(scale.x, scale.y, scale.x),
scale,
rotation,
attribs: Vec::new(),
cols: 1,
@@ -223,7 +223,8 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option
ins
}
/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]).
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
/// [`insert_entity`]).
pub(crate) fn insert_with(
s: &mut Session,
name: &str,
@@ -232,6 +233,19 @@ pub(crate) fn insert_with(
rotation: f64,
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
) -> Result<Handle> {
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
}
/// [`insert_with`] with a Z scale factor of its own.
fn insert_scaled(
s: &mut Session,
name: &str,
at: Vec2,
scale: Vec3,
rotation: f64,
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
) -> Result<Handle> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let mut ins = insert_entity(&blk, at, scale, rotation, base);
@@ -566,15 +580,19 @@ enum InsStage {
/// A file whose block already exists: redefine it from the file?
Redefine,
Point,
/// Basepoint: a new base point, picked on the block shown at its definition position.
/// Basepoint: a new base point, picked on the block dropped where it was (at the cursor).
BasePoint,
/// Scale preset before the insertion point.
PresetScale,
/// X, Y or Z scale preset (0, 1, 2) before the insertion point: that axis alone.
PresetAxis(usize),
/// Rotate preset before the insertion point.
PresetRotation,
XScale,
Corner,
YScale,
/// XYZ: the Z scale factor, after X and Y.
ZScale,
Rotation,
Attribs,
}
@@ -587,6 +605,15 @@ struct InsertM {
base: Option<Vec2>,
x_scale: Option<f64>,
scale: Option<Vec2>,
/// The Z scale factor when given (a preset or XYZ); otherwise the X factor's size. AutoCAD
/// keeps a Z factor typed at XYZ's Z prompt as given, and stores the size of any other.
z_scale: Option<f64>,
/// X/Y/Z presets given so far.
axes: [Option<f64>; 3],
/// XYZ at the X scale prompt: X, Y and Z are asked in turn.
xyz: bool,
/// Where the block was dropped to pick a Basepoint on it.
dropped: Option<Vec2>,
rotation: Option<f64>,
attdefs: Vec<(String, String, String)>,
values: serde_json::Map<String, Value>,
@@ -610,7 +637,7 @@ impl InsertM {
&& self.values.len() >= self.attdefs.len()
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
{
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
if self.explode {
insert_file::explode_insert(s, h)?;
}
@@ -656,6 +683,23 @@ impl InsertM {
}
}
/// X/Y scale factors with Z: as given, else the X factor's size (AutoCAD 2024: X -1 with Y
/// as X stores Z +1).
/// X and Y from a corner: XYZ still asks Z, which defaults to the size of X.
fn corner_scale(&mut self, s: &mut Session, sc: Vec2) -> Result<Step> {
self.scale = Some(sc);
if self.xyz {
self.x_scale = Some(sc.x);
self.stage = InsStage::ZScale;
return Ok(Step::Continue);
}
self.advance(s)
}
fn scale3(&self, sc: Vec2) -> Vec3 {
Vec3::new(sc.x, sc.y, self.z_scale.unwrap_or(sc.x.abs()))
}
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
let d = p - a;
@@ -686,15 +730,18 @@ impl Interactive for InsertM {
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
}
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
InsStage::PresetAxis(i) => Prompt::new(format!("Specify {} scale factor", ["X", "Y", "Z"][i.min(2)]), Accept::NUMBER).default("1"),
InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
InsStage::XScale if self.xyz => Prompt::new("Specify X scale factor", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at),
InsStage::XScale => {
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at)
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner", "XYZ"]).default("1").base(at)
}
InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at),
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
InsStage::Attribs => {
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
@@ -742,14 +789,25 @@ impl Interactive for InsertM {
self.at = Some(p);
return self.advance(s);
}
Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint,
Input::Keyword(k) if k == "Basepoint" => {
// AutoCAD drops the block where it is being dragged, at the cursor (the origin
// in a script: 2024 inserted (15, 5) for base point (612350.678, 2398770.432)
// and insertion point (612365.678, 2398775.432)).
self.dropped = Some(s.cursor);
self.stage = InsStage::BasePoint;
}
Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale,
Input::Keyword(k) if k == "X" => self.stage = InsStage::PresetAxis(0),
Input::Keyword(k) if k == "Y" => self.stage = InsStage::PresetAxis(1),
Input::Keyword(k) if k == "Z" => self.stage = InsStage::PresetAxis(2),
Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation,
_ => {}
},
InsStage::BasePoint => {
if let Input::Point(p) = i {
self.base = Some(p);
// From the dropped block to its own coordinates.
let def = s.doc()?.block(self.name.as_deref().unwrap_or_default()).map_or(Vec2::ZERO, |b| b.base.xy());
self.base = Some(def + (p - self.dropped.unwrap_or(def)));
self.stage = InsStage::Point;
}
}
@@ -760,12 +818,30 @@ impl Interactive for InsertM {
_ => return Ok(Step::Continue),
};
self.scale = Some(Vec2::new(f, f));
self.z_scale = Some(f.abs());
// An axis preset after it changes that axis alone (Scale -3 then X 2: 2/-3/3).
self.axes = [Some(f), Some(f), Some(f.abs())];
// Exploded insertion asks its one scale factor after the insertion point.
if self.at.is_some() {
return self.advance(s);
}
self.stage = InsStage::Point;
}
// One axis preset: the others are 1, and no scale factors are asked after the point.
InsStage::PresetAxis(ax) => {
let f = match i {
Input::Text(t) => nonzero(&t)?,
Input::Enter => 1.0,
_ => return Ok(Step::Continue),
};
if let Some(a) = self.axes.get_mut(ax) {
*a = Some(f);
}
let [x, y, z] = self.axes.map(|a| a.unwrap_or(1.0));
self.scale = Some(Vec2::new(x, y));
self.z_scale = Some(z.abs());
self.stage = InsStage::Point;
}
InsStage::PresetRotation => {
self.rotation = Some(match i {
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
@@ -776,9 +852,10 @@ impl Interactive for InsertM {
}
InsStage::XScale => match i {
Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner,
Input::Keyword(k) if k == "XYZ" => self.xyz = true,
Input::Point(p) => {
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
return self.advance(s);
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
return self.corner_scale(s, sc);
}
Input::Text(t) => {
self.x_scale = Some(nonzero(&t)?);
@@ -792,8 +869,8 @@ impl Interactive for InsertM {
},
InsStage::Corner => {
if let Input::Point(p) = i {
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
return self.advance(s);
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
return self.corner_scale(s, sc);
}
}
InsStage::YScale => {
@@ -804,6 +881,18 @@ impl Interactive for InsertM {
_ => return Ok(Step::Continue),
};
self.scale = Some(Vec2::new(x, y));
if self.xyz {
self.stage = InsStage::ZScale;
return Ok(Step::Continue);
}
return self.advance(s);
}
InsStage::ZScale => {
self.z_scale = Some(match i {
Input::Text(t) => nonzero(&t)?,
Input::Enter => self.x_scale.unwrap_or(1.0).abs(),
_ => return Ok(Step::Continue),
});
return self.advance(s);
}
InsStage::Rotation => {
@@ -832,7 +921,7 @@ impl Interactive for InsertM {
let Some(b) = d.block(n) else { return Vec::new() };
// Picking a new base point: the block sits at its definition position.
if self.stage == InsStage::BasePoint {
return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))];
return vec![EntityKind::Insert(insert_entity(b, self.dropped.unwrap_or(b.base.xy()), Vec3::new(1.0, 1.0, 1.0), 0.0, None))];
}
let at = self.at.unwrap_or(c);
let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) {
@@ -841,7 +930,7 @@ impl Interactive for InsertM {
_ => Vec2::new(1.0, 1.0),
});
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))]
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
}
}
+19 -3
View File
@@ -3,7 +3,7 @@
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
use cadcraft_doc::color::Color;
use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};
use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign};
use serde_json::{Map, Value, json};
use crate::cmd::curves::KW;
@@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
if let EntityKind::Insert(i) = &mut e.kind {
for a in &mut i.attribs {
if let Some(v) = value_for(&vals, &a.tag) {
a.text.value = v;
set_value(&mut a.text, v);
n += 1;
}
}
@@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option
&& let Some(a) = ins.attribs.get_mut(k)
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
{
a.text.value = v;
set_value(&mut a.text, v);
changed = true;
}
})?;
Ok(changed)
}
/// Refresh the derived baseline after a value change, even one within the writer's
/// imported-font tolerance. Equal XY points request recomputation on export; retain
/// both elevations and the independent endpoints used by Aligned/Fit text.
fn set_value(text: &mut Text, value: String) {
if text.value == value {
return;
}
text.value = value;
if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _))
&& let Some(anchor) = text.align_pt
{
text.insert.x = anchor.x;
text.insert.y = anchor.y;
}
}
/// Change the properties set on attribute `k` of block reference `h`.
fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> {
s.doc_mut()?.modify_entity(h, |e| {
+101 -21
View File
@@ -2,6 +2,8 @@
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
//! BEDIT while the Block Editor is open switches it to another block: changes not saved yet are
//! saved or discarded first (asked at the command line; `save` in JSON).
//! The state is [`crate::blockedit`].
use cadcraft_doc::{Drawing, EntityKind, Handle};
@@ -16,10 +18,9 @@ pub(super) fn specs() -> Vec<CommandSpec> {
CommandSpec::new("bedit", "Block Editor", run_bedit)
.menu(&["Tools", "Block Editor"])
.alias(&["be", "-bedit"])
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
.enabled(not_editing)
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it), save?: bool (in the Block Editor, switching to another block: save (true) or discard (false) the changes not saved yet; required when there are some), check?: bool (in the Block Editor: only check that the block can be switched to)} → {block, new, saved?}")
.noundo()
.interactive(|_| Ok(Box::new(BeditM))),
.interactive(|_| Ok(Box::new(BeditM::default()))),
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
@@ -53,16 +54,6 @@ fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
}
}
fn not_editing(s: &Session) -> std::result::Result<(), String> {
match s.state() {
Ok(st) => match st.block_editor() {
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
None => Ok(()),
},
Err(e) => Err(e.to_string()),
}
}
/// Commands that leave model space, which the Block Editor is: not available while it is open.
const NOT_IN_EDITOR: &[&str] = &[
"layout",
@@ -106,8 +97,53 @@ fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
}
/// Open the Block Editor on block `name` and frame its objects.
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
/// Whether the open Block Editor can switch to block `name`: the block is there (or `create` makes
/// it) and isn't one the editor refuses. Checked before anything is saved or closed, or asked.
fn check_target(s: &Session, name: &str, create: bool) -> Result<()> {
let host = s.state()?.block_edit.as_ref().map(|b| b.host.doc.clone()).ok_or_else(|| bad("bedit", "the Block Editor is not open"))?;
if name.trim().eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) {
return Ok(());
}
match host.block(name) {
None if !create => Err(bad("bedit", format!("Block \"{name}\" not found."))),
None => Ok(()),
Some(_) => crate::blockedit::refusal(&host, name).map_or(Ok(()), |why| Err(bad("bedit", why))),
}
}
/// Open the Block Editor on block `name` and frame its objects. While it is open on another
/// block it switches: the changes not saved yet are saved first with `save` Some(true),
/// discarded with Some(false); with None there must be none. The block to switch to is checked
/// before anything is saved or closed, so a refused switch leaves the editor as it was.
fn open(s: &mut Session, name: &str, create: bool, save: Option<bool>) -> Result<Value> {
let mut saved = None;
if let Some(current) = s.state()?.block_editor().map(str::to_string) {
if current.eq_ignore_ascii_case(name.trim()) {
s.echo(format!("The Block Editor is already editing \"{current}\"."));
return Ok(json!({ "block": current, "new": false }));
}
check_target(s, name, create)?;
let save = match (s.state()?.block_unsaved(), save) {
(false, _) => false,
(true, Some(save)) => save,
(true, None) => {
return Err(bad(
"bedit",
format!("Block \"{current}\" has changes not saved; pass `save: true` to save them or `save: false` to discard them"),
));
}
};
saved = close(s, save)?.get("saved").cloned();
}
let mut r = open_closed(s, name, create)?;
if let (Some(v), Some(o)) = (saved, r.as_object_mut()) {
o.insert("saved".into(), v);
}
Ok(r)
}
/// [`open`] with the Block Editor closed.
fn open_closed(s: &mut Session, name: &str, create: bool) -> Result<Value> {
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
s.state_mut()?.open_block_editor(name, create)?;
let name = s.state()?.block_editor().unwrap_or(name).to_string();
@@ -153,7 +189,15 @@ fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
}
open(s, name, create)
if p.get("save").is_some_and(|v| !v.is_null() && !v.is_boolean()) {
return Err(bad("bedit", "`save` must be true or false"));
}
// Only whether the Block Editor, when open, can switch to the block: nothing opens or closes.
if bool_or(p, "check", false) && s.state()?.block_editor().is_some() {
check_target(s, name, create)?;
return Ok(json!({ "block": name, "checked": true }));
}
open(s, name, create, p.get("save").and_then(Value::as_bool))
}
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -206,7 +250,27 @@ fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------- interactive ----------------
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
struct BeditM;
/// In the Block Editor with changes not saved yet, it asks whether to save them before switching.
#[derive(Default)]
struct BeditM {
/// The block to switch to, waiting for the answer to "Save changes?".
pending: Option<String>,
}
impl BeditM {
/// Go to block `name`: at once, or after asking about the changes not saved yet.
fn go(&mut self, s: &mut Session, name: &str) -> Result<Step> {
let st = s.state()?;
let other = st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(name.trim()));
if other && st.block_unsaved() {
// A block it can't switch to is refused before the question, which would trap the user.
check_target(s, name.trim(), false)?;
self.pending = Some(name.trim().to_string());
return Ok(Step::Continue);
}
open(s, name, false, Some(false)).map(|_| Step::Done)
}
}
impl Interactive for BeditM {
fn name(&self) -> &'static str {
@@ -214,14 +278,30 @@ impl Interactive for BeditM {
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
match selected_reference(s) {
Some(name) => open(s, &name, false).map(|_| Step::Done),
Some(name) => self.go(s, &name),
None => Ok(Step::Continue),
}
}
fn prompt(&self, _s: &Session) -> Prompt {
fn prompt(&self, s: &Session) -> Prompt {
if self.pending.is_some() {
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
return Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y");
}
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(name) = self.pending.clone() {
let answer = match i {
Input::Enter => "Y".to_string(),
Input::Keyword(k) | Input::Text(k) => k,
_ => return Ok(Step::Continue),
};
return match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
Some('Y') => open(s, &name, false, Some(true)).map(|_| Step::Done),
Some('N') => open(s, &name, false, Some(false)).map(|_| Step::Done),
_ => Err(EngineError::Other("Enter Yes or No.".into())),
};
}
match i {
Input::Text(t) if t.trim() == "?" => {
let names: Vec<String> = {
@@ -231,14 +311,14 @@ impl Interactive for BeditM {
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
Ok(Step::Continue)
}
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
Input::Text(t) | Input::Keyword(t) => self.go(s, t.trim()),
Input::Pick(hs) => {
let name = {
let d = s.doc()?;
hs.iter().find_map(|h| block_of(d, *h))
};
match name {
Some(n) => open(s, &n, false).map(|_| Step::Done),
Some(n) => self.go(s, &n),
None => Err(EngineError::Other("Select a block reference.".into())),
}
}
+3 -3
View File
@@ -461,7 +461,7 @@ impl Interactive for GcM {
}
Input::Point(p) => {
let space = s.space();
let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else {
let Some(h) = s.pick_object(&space, p, aperture(s))? else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
@@ -706,11 +706,11 @@ impl Interactive for DcM {
Input::Point(p) => {
let space = s.space();
let ap = aperture(s);
let d = s.doc()?;
let Some(h) = crate::select::pick(d, &space, p, ap) else {
let Some(h) = s.pick_object(&space, p, ap)? else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let d = s.doc()?;
let want = match dc {
Dc::Radius | Dc::Diameter => Want::Curve,
Dc::Angular if self.refs.is_empty() => {
+38 -3
View File
@@ -18,10 +18,9 @@ pub fn pick_aperture(s: &Session) -> f64 {
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
}
/// The object under `p` in the current space.
/// The object under `p` in the current space (or the one named for `p`: [`Session::pick_object`]).
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
let space = s.space();
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
s.pick_object(&s.space(), p, pick_aperture(s)).ok().flatten()
}
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
@@ -479,6 +478,19 @@ impl Chain {
}
best.1
}
/// The curve parameter of the point on the chain nearest `p`: the index of its segment plus
/// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc).
pub fn param_of(&self, p: Vec2) -> f64 {
let mut best = (f64::INFINITY, 0.0);
for (i, s) in self.segs.iter().enumerate() {
let c = s.closest(p);
let dd = c.dist(p);
if dd < best.0 {
best = (dd, i as f64 + seg_param(s, c));
}
}
best.1
}
/// Reversed copy.
pub fn reversed(&self) -> Chain {
Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
@@ -506,6 +518,29 @@ impl Chain {
}
}
/// The polyline parameter of the point nearest `p` on spans `(start, end, bulge)` taken from the
/// source vertices: the span's index plus the fraction along it. Unlike [`Chain::param_of`] it
/// counts zero-length spans, which a polyline's parameter range includes (one per vertex) but its
/// chain drops.
pub fn vertex_param_of(spans: &[(Vec2, Vec2, f64)], p: Vec2) -> f64 {
let mut best = (f64::INFINITY, 0.0);
for (i, &(a, b, bulge)) in spans.iter().enumerate() {
if a.near(b, EPS) {
continue;
}
let seg = match cadcraft_geom::bulge_to_arc(a, b, bulge) {
Some((arc, ccw)) => Segment::Arc { arc, ccw },
None => Segment::Line(Line::new(a, b)),
};
let c = seg.closest(p);
let dd = c.dist(p);
if dd < best.0 {
best = (dd, i as f64 + seg_param(&seg, c));
}
}
best.1
}
/// Parameter (0..1 along travel) of a point on a segment.
pub fn seg_param(s: &Segment, p: Vec2) -> f64 {
match s {
+8 -1
View File
@@ -1,5 +1,5 @@
//! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY,
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
//! SPLINE, DONUT, SOLID, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
@@ -21,6 +21,7 @@ mod ellipse;
mod line_pline;
mod point;
mod polygon;
mod solid;
mod spline;
mod text;
@@ -30,6 +31,7 @@ use ellipse::{EllipseM, run_ellipse};
use line_pline::{LineM, PlineM, run_line, run_pline};
use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
use polygon::{PolygonM, run_polygon};
use solid::{SolidM, run_solid};
use spline::{SplineM, run_spline};
use text::{MTextM, TextM, run_mtext, run_text};
@@ -100,6 +102,11 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["do", "doughnut"])
.params("{center, inside, outside}")
.interactive(|_| Ok(Box::new(DonutM::default()))),
CommandSpec::new("solid", "2D Solid", run_solid)
.menu(&["Draw", "2D Solid"])
.alias(&["so"])
.params("{points: [[x,y],...]} (three or four for the first solid, two more for each next one, chained on its last two corners)")
.interactive(|_| Ok(Box::new(SolidM::default()))),
CommandSpec::new("text", "Single Line Text", run_text)
.menu(&["Draw", "Text", "Single Line Text"])
.alias(&["dt", "dtext"])
+38 -9
View File
@@ -34,19 +34,24 @@ pub(super) struct EllipseM {
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
param: bool,
start: Option<f64>,
/// The `Included angle` option: the next value is the arc's angle from its start angle.
included: bool,
/// The Isocircle option (isometric snap style) in progress.
iso: Option<crate::cmd::settings::iso::IsoCircle>,
}
impl EllipseM {
fn axis(&self) -> Option<(Vec2, Vec2)> {
// (center, major vector)
// (center, major vector): towards the first axis endpoint, as AutoCAD stores it.
match (self.center_mode, self.pts.as_slice()) {
(true, [c, e, ..]) => Some((*c, *e - *c)),
(false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
(false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))),
_ => None,
}
}
/// The other half axis reaches as far as `other` is from the center (AutoCAD measures the
/// picked point's distance from the center, not from the first axis). Longer than the
/// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise.
fn build(&self, other: Vec2) -> Option<Ellipse> {
let (c, m) = self.axis()?;
let ml = m.len();
@@ -54,7 +59,7 @@ impl EllipseM {
return None;
}
let u = m / ml;
let minor = ((other - c).dot(u.perp())).abs();
let minor = (other - c).len();
let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };
(ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))
}
@@ -88,13 +93,16 @@ impl Interactive for EllipseM {
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(e) = &self.ellipse {
let msg = match (self.start.is_none(), self.param) {
(true, false) => "Specify start angle",
(false, false) => "Specify end angle",
(true, true) => "Specify start parameter",
(false, true) => "Specify end parameter",
if self.included {
return Prompt::new("Specify included angle for arc", Accept::POINT_OR_NUMBER).default("180").base(e.center);
}
let (msg, kw): (&str, &[&str]) = match (self.start.is_none(), self.param) {
(true, false) => ("Specify start angle", &["Parameter"]),
(false, false) => ("Specify end angle", &["Parameter", "Included angle"]),
(true, true) => ("Specify start parameter", &["Angle"]),
(false, true) => ("Specify end parameter", &["Angle"]),
};
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center);
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(kw).base(e.center);
}
if let Some(iso) = &self.iso {
return iso.prompt();
@@ -112,11 +120,32 @@ impl Interactive for EllipseM {
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(e) = self.ellipse {
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
if self.included {
let incl = match i {
Input::Point(p) => (p - e.center).angle(),
Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?,
Input::Enter => std::f64::consts::PI,
_ => return Ok(Step::Continue),
};
// A true angle added to the start angle (AutoCAD: start 214.659°, included
// 232.716° ends the arc at 87.375° from the major axis).
let st = self.start.unwrap_or(0.0);
let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl));
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
center: v3(e.center),
major: v3(e.major),
ratio: e.ratio,
start: st,
end,
}))?;
return Ok(Step::Done);
}
let ang = match i {
Input::Keyword(k) => {
match k.as_str() {
"Parameter" => self.param = true,
"Angle" => self.param = false,
"Included angle" if self.start.is_some() && !self.param => self.included = true,
_ => {}
}
return Ok(Step::Continue);
+79
View File
@@ -0,0 +1,79 @@
//! SOLID: filled triangles and quadrilaterals (2D solids), chained as AutoCAD 2024 chains them.
//!
//! The prompts are "Specify first point", "Specify second point", "Specify third point" and
//! "Specify fourth point or <exit>"; Enter there makes a triangle (the fourth corner repeats the
//! third). Then "Specify third point" and "Specify fourth point or <exit>" come again for the next
//! solid, whose first two corners are the previous one's last two, until Enter at "Specify third
//! point". Enter there right after the second point makes nothing.
use cadcraft_doc::Solid;
use super::{Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, v3};
/// A 2D solid from its corners in AutoCAD's order (the fourth is drawn opposite the first).
fn solid(c: [Vec2; 4]) -> EntityKind {
EntityKind::Solid(Solid { corners: c.map(v3) })
}
/// `{points}`: the first solid takes three or four points, each further one two more, its first
/// two corners being the previous one's last two; a solid given three points is a triangle.
pub(super) fn run_solid(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("solid", "`points` is required"))?;
let mut it = pts.into_iter();
let (Some(mut a), Some(mut b), Some(_)) = (it.next(), it.next(), it.clone().next()) else {
return Err(bad("solid", "`points` needs at least three points"));
};
let mut hs = Vec::new();
while let Some(c) = it.next() {
let d = it.next().unwrap_or(c);
hs.push(s.add_entity(solid([a, b, c, d]))?.hex());
(a, b) = (c, d);
}
Ok(json!({ "handles": hs }))
}
#[derive(Default)]
pub(super) struct SolidM {
/// The corners of the solid being drawn (the first two from the previous one after it).
pts: Vec<Vec2>,
}
impl Interactive for SolidM {
fn name(&self) -> &'static str {
"SOLID"
}
fn prompt(&self, _s: &Session) -> Prompt {
let last = self.pts.last().copied();
match self.pts.len() {
0 => Prompt::new("Specify first point", Accept::POINT),
1 => Prompt::new("Specify second point", Accept::POINT).base_opt(last),
2 => Prompt::new("Specify third point", Accept::POINT).base_opt(last),
_ => Prompt::new("Specify fourth point or", Accept::POINT).default("exit").base_opt(last),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (i, self.pts.as_slice()) {
(Input::Point(p), &[a, b, c]) => self.make(s, [a, b, c, p])?,
(Input::Point(p), _) => self.pts.push(p),
(Input::Enter, &[a, b, c]) => self.make(s, [a, b, c, c])?,
(Input::Enter, _) => return Ok(Step::Done),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, cursor: Vec2) -> Vec<EntityKind> {
match *self.pts.as_slice() {
[a, b] => vec![solid([a, b, cursor, cursor])],
[a, b, c] => vec![solid([a, b, c, cursor])],
_ => Vec::new(),
}
}
}
impl SolidM {
fn make(&mut self, s: &mut Session, c: [Vec2; 4]) -> Result<()> {
s.add_entity(solid(c))?;
self.pts = vec![c[2], c[3]];
Ok(())
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(TanCircleM::new(false)))),
CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis)
.menu(&["Draw", "Ellipse", "Axis, End"])
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)")
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)")
.interactive(|_| Ok(super::draw::ellipse_machine(false, false))),
CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc)
.menu(&["Draw", "Ellipse", "Arc"])
+37 -6
View File
@@ -74,12 +74,43 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {
}
let block = str_param(p, "block").map(str::to_string);
check_block(s, block.as_deref())?;
if let Some(from) = point_param(p, "from")
&& !c.closed
&& let (Some(a), Some(b)) = (c.start(), c.end())
&& from.dist(b) < from.dist(a)
{
c = c.reversed();
// AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index
// plus the fraction along it), not by distance: an open curve from its end once the pick is
// halfway or further, a closed one from its first vertex, backwards from halfway on. A
// circle always runs counterclockwise from angle 0.
if let Some(from) = point_param(p, "from") {
let past_half = match curves::entity(s, h)?.kind {
EntityKind::Circle(_) => false,
// At an arc's centre, where a bare name given from AutoLISP stands (its DXF group 10
// point), every point of it is as near: AutoCAD measures from its end.
EntityKind::Arc(a) if from.dist(a.center.xy()) <= a.radius * 1e-9 => true,
// A spline by its own parameter: its tessellation drops zero-length pieces, so the
// segment count is not the spline's parameter range.
EntityKind::Spline(sp) => {
let (lo, hi) = sp.domain();
curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0
}
// Polylines by their vertices, coincident ones included (one parameter unit each,
// as in AutoCAD), which the chain drops.
EntityKind::LwPolyline(pl) => {
let n = pl.vertices.len();
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
.filter_map(|i| Some((pl.vertices.get(i)?.p, pl.vertices.get((i + 1) % n)?.p, pl.vertices.get(i)?.bulge)))
.collect();
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
}
EntityKind::Polyline3d(pl) => {
let n = pl.points.len();
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
.filter_map(|i| Some((pl.points.get(i)?.xy(), pl.points.get((i + 1) % n)?.xy(), 0.0)))
.collect();
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
}
_ => c.param_of(from) >= c.segs.len() as f64 / 2.0,
};
if past_half {
c = c.reversed();
}
}
let pts = measure_points(&c, step);
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
+5 -6
View File
@@ -1,10 +1,11 @@
use super::{Ellipse, EntityKind, Line, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
use super::{Ellipse, EntityKind, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
// ---------------- ellipse entries ----------------
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
let c = p1.mid(p2);
let m = p2 - c;
// The major axis points at the first endpoint, as AutoCAD stores it.
let m = p1 - c;
let ml = m.len();
let half = half.abs();
if ml < 1e-12 || half < 1e-12 || !half.is_finite() {
@@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {
let p1 = point_req(cmd, p, "p1")?;
let p2 = point_req(cmd, p, "p2")?;
let half = match point_param(p, "p3") {
Some(p3) => {
let ax = Line::new(p1, p2);
ax.project(p3).dist(p3)
}
// As at the prompt: the point's distance from the center, not from the first axis.
Some(p3) => p3.dist(p1.mid(p2)),
None => f64_req(cmd, p, "distance")?,
};
ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))
+385
View File
@@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() {
}
}
/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic
/// input in accoreconsole, read back with `entget`). The major axis points at the first axis
/// endpoint; a point at the distance prompt gives its distance from the center; an other axis
/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the
/// Included angle is added to the start angle.
#[test]
fn ellipse_axis_endpoints_match_autocad() {
type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>);
// (input after ELLIPSE, center, major axis, ratio, arc start/end parameters)
let cases: [Case; 10] = [
// The other axis (27.02) is longer than the first half axis (26.42).
(
"16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268",
[5.712514913615239, 3.985817249587547],
[-24.6850622596004, 10.99222700689644],
0.9776866627982613,
None,
),
(
"33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949",
[17.27018583472502, -18.93707805189049],
[16.1799716942092, -9.060587202842658],
0.8052876303433125,
None,
),
(
"0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256",
[0.5074363325764704, 0.2533737894427027],
[0.00868846668462886, 0.02043019027014691],
0.1200276615332929,
None,
),
// Arcs: the angles are taken from the major axis, so a reversed axis draws the arc
// on the opposite side of the center.
(
"_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204",
[612359.8301610444, 2398774.152511537],
[-5.160876934416592, 22.44292051764205],
0.1535548572193783,
Some([4.683678586016272, 2.014340486820176]),
),
(
"_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654",
[-8.068629780716847, 9.577702534161936],
[-24.42926629106581, 16.24859713260318],
0.317871961092373,
Some([4.107654908771007, 5.952182383669234]),
),
(
"_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933",
[15.89513193856356, -16.13523652037361],
[2.53204749973717, -14.23341561039267],
0.9785367111972643,
Some([3.756694588158375, 1.5259701966906878]),
),
(
"_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075",
[1.882264537326193, -7.920292708078534],
[19.15838502833398, 33.68853210335513],
0.8017236937148501,
Some([2.267136066480607, 4.657903136501946]),
),
// The other axis given by a point.
(
"-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656",
[-15.97844303459893, 15.52013568457562],
[-0.3990375230131046, 39.92686201604709],
0.7411292927991227,
None,
),
(
"8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266",
[2.843616795535609, 19.31530728820197],
[4.602217644463162, 7.450176331143755],
0.7105482899194384,
None,
),
(
"_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446",
[15.41818958649589, -18.74113376930122],
[27.87548765369622, 24.07822440243505],
0.3302549104883693,
None,
),
];
use std::f64::consts::{PI, TAU};
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
let same_angle = |a: f64, b: f64| {
let d = (a - b).rem_euclid(TAU);
d < 1e-9 || TAU - d < 1e-9
};
for (input, center, major, ratio, arc) in cases {
let mut s = Session::new();
s.cmdline(&format!("ellipse {input}")).unwrap();
assert!(s.running.is_none(), "{input}: {}", s.prompt_text());
let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") };
assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center);
assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major);
assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio);
let [start, end] = arc.unwrap_or([0.0, TAU]);
assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end);
}
// Enter takes the default included angle, 180°: half the ellipse.
let mut s = Session::new();
s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap();
assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text());
s.input(Input::Enter).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") };
assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end);
}
// ---------------- DIVIDE / MEASURE ----------------
#[test]
@@ -302,6 +414,279 @@ fn divide_and_measure_json() {
assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err());
}
/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick
/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`,
/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not
/// by distance; a closed polyline runs from its first vertex, a circle from angle 0.
#[test]
fn divide_and_measure_match_autocad() {
type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]);
// ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points)
let cases: Vec<Case> = vec![
// DIVIDE-001: (list ename point).
(
("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})),
"divide",
5.0,
[-2.090569265353071, 19.890437907365467],
4,
&[
[-19.021130325903, 6.180339887499],
[-17.975880925983, -8.767422935782],
[-9.389431255718, 17.658951857179],
[5.51274711634, 19.225233918766],
],
),
// DIVIDE-002: (list ename point).
(
("circle", json!({"center": [0.0, 0.0], "radius": 15.0})),
"divide",
5.0,
[-4.63525491562421, 14.265847744427305],
5,
&[
[-12.135254915624, -8.816778784387],
[-12.135254915624, 8.816778784387],
[4.635254915624, -14.265847744427],
[4.635254915624, 14.265847744427],
[15.0, 0.0],
],
),
// MEASURE-016: (list ename point).
(
("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})),
"measure",
7.688652713279647,
[25.9, 9.1],
5,
&[
[0.730313187001, 0.256596525162],
[7.984250549601, 2.80527722013],
[15.2381879122, 5.353957915097],
[22.4921252748, 7.902638610065],
[29.7460626374, 10.451319305032],
],
),
// MEASURE-024: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}),
),
"measure",
7.0,
[90.0, 0.0],
29,
&[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]],
),
// MEASURE-026: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}),
),
"measure",
7.0,
[10.0, 30.0],
15,
&[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]],
),
// MEASURE-030: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
),
"measure",
7.5,
[5.0, 17.5],
17,
&[
[2.06040845923, 7.211429607306],
[4.120816918461, 14.422859214612],
[5.957390538273, 0.0],
[41.854257779673, 5.445746712402],
[45.137412221497, 19.497902817843],
[45.215233654661, 12.07010340037],
],
),
// DIVIDE-028: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
),
"divide",
5.0,
[37.5, 10.0],
5,
&[
[0.0, 0.0],
[7.332712970983, 25.664495398441],
[25.609294779385, 28.310302237406],
[26.691478107655, 0.0],
[45.58190593662, 14.465330916891],
],
),
// MEASURE-041: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
),
"measure",
7.5,
[45.54951565955276, 17.15901034980981],
17,
&[
[1.636621047999, 5.728173667998],
[3.69702950723, 12.939603275304],
[5.75743796646, 20.15103288261],
[42.799868254703, 6.663750217618],
[44.060611317761, 20.402595149531],
[45.491536690495, 13.587129550966],
],
),
// MEASURE-042: (list ename point).
(
(
"pline",
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
),
"measure",
7.5,
[44.0, 20.5],
17,
&[
[2.06040845923, 7.211429607306],
[4.120816918461, 14.422859214612],
[5.957390538273, 0.0],
[41.854257779673, 5.445746712402],
[45.137412221497, 19.497902817843],
[45.215233654661, 12.07010340037],
],
),
];
for ((seed, params), what, value, pick, count, want) in cases {
let mut s = Session::new();
let r = s.execute(seed, &params).unwrap();
let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string();
// AutoLISP's (list ename point): the object and where it was picked.
let params = if what == "divide" {
json!({"handle": handle, "segments": value as u64, "from": pick})
} else {
json!({"handle": handle, "length": value, "from": pick})
};
s.execute(what, &params).unwrap();
let pts: Vec<Vec2> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}");
for w in want {
let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0);
assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}");
}
}
}
/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a
/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and
/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length.
#[test]
fn measure_spline_by_its_own_parameter() {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap());
s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap();
let pts: Vec<Vec2> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}");
}
/// MEASURE counts every vertex in a polyline's parameter, coincident ones too (AutoCAD's polyline
/// parameter is the vertex index; the chain drops zero-length spans), and picks an ellipse's end
/// by the ellipse parameter like any other curve: AutoCAD 2024 runs both measured cases below from
/// the end (black-box, accoreconsole, POINTs read back with `entget`).
#[test]
fn measure_counts_coincident_vertices_and_runs_ellipses_by_parameter() {
let pts = |s: &Session| -> Vec<Vec2> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect()
};
// (0,0) (1,0) (101,0) (101,0) picked at x = 1.1: parameter 1.001 of 3, before the middle, so
// from the start (the chain alone, 1.001 of 2, ran it from the end).
let mut s = Session::new();
let pl = h(&s.execute("pline", &json!({"vertices": [[0, 0], [1, 0], [101, 0], [101, 0]]})).unwrap());
s.execute("measure", &json!({"handle": pl, "length": 30, "from": [1.1, 0]})).unwrap();
assert!(pts(&s).iter().any(|p| near(*p, 30.0, 0.0)), "{:?}", pts(&s));
// (0,0) (0,0) (10,0) (20,0) picked at x = 7: parameter 1.7 of 3, past the middle, so from the
// end; the same as a 3D polyline.
for (cmd, params) in [
("pline", json!({"vertices": [[0, 0], [0, 0], [10, 0], [20, 0]]})),
("3dpoly", json!({"points": [[0, 0, 0], [0, 0, 0], [10, 0, 0], [20, 0, 0]]})),
] {
let mut s = Session::new();
let pl = h(&s.execute(cmd, &params).unwrap());
s.execute("measure", &json!({"handle": pl, "length": 3, "from": [7, 0]})).unwrap();
assert!(pts(&s).iter().any(|p| near(*p, 17.0, 0.0)), "{cmd}: {:?}", pts(&s));
}
// AutoCAD's points. CADCraft measures along a tessellation (1e-4 of the major axis), so its
// points sit within 2e-4 of the axis of AutoCAD's; the other end is 4 to 200 units away.
type Ellipse = ([f64; 2], [f64; 2], f64, f64, f64);
let cases: [((Ellipse, f64, [f64; 2]), &[[f64; 2]]); 2] = [
// MEASURE-032-TO: a full ellipse picked at t = pi.
(
(([-35000.0, 81000.0], [1000.0, 250.0], 0.5, 0.0, std::f64::consts::TAU), 325.0, [-36000.0, 80750.0]),
&[
[-36005.13895764338, 80852.59066530415],
[-35871.99367437639, 80574.74515309861],
[-35846.48143804769, 81129.79467385833],
[-35585.0606965763, 81321.20806711384],
[-35570.69066464184, 80460.16118260568],
[-35288.01208649818, 81451.990340526],
[-35246.80459288685, 80443.34080344986],
[-34973.3365764063, 81531.8855660685],
[-34925.28109250207, 80488.09750605426],
[-34649.89308676404, 81558.94977503068],
[-34615.08169799787, 80583.88008545575],
[-34328.55263503237, 81517.06267451325],
[-34326.49236487653, 80732.20222770158],
[-34085.24055588816, 80947.67206458953],
[-34052.78599900089, 81354.8654746607],
],
),
// MEASURE-033: an elliptical arc picked past its middle.
(
(([0.0, 0.0], [20.0, 5.0], 0.5, 0.5, 4.0), 4.25, [-14.28970951093154, -10.073417468999274]),
&[
[-20.14815072114992, -3.432193431111516],
[-18.74987859459168, -7.329883069345779],
[-18.68559214389327, 0.5029121733397516],
[-15.81338990685036, 3.617741906558092],
[-15.26656288688172, -9.70206428611582],
[-12.33992351852055, 6.057945492888583],
[-8.547503008323163, 7.9699830580747],
[-4.557798848614094, 9.428419587410612],
[-0.4368018715850841, 10.45983604769949],
[3.769519107506058, 11.05284388252232],
[8.015496424961295, 11.15319778065308],
[12.22912105170455, 10.6383262710487],
[16.23125506912425, 9.247174221779819],
],
),
];
for (((center, major, ratio, t0, t1), length, from), want) in cases {
let mut s = Session::new();
let e = h(&s
.execute("ellipse", &json!({"center": center, "major": major, "ratio": ratio, "start": t0.to_degrees(), "end": t1.to_degrees()}))
.unwrap());
s.execute("measure", &json!({"handle": e, "length": length, "from": from})).unwrap();
let got = pts(&s);
let tol = 2e-4 * major[0].hypot(major[1]);
assert_eq!(got.len(), want.len(), "{got:?}");
for w in want {
assert!(got.iter().any(|p| p.dist(Vec2::new(w[0], w[1])) < tol), "no point near {w:?} in {got:?}");
}
}
}
#[test]
fn divide_and_measure_command_line() {
let mut s = Session::new();
+5 -4
View File
@@ -754,19 +754,20 @@ mod tests {
s.cmdline("").unwrap();
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
s.cmdline("").unwrap();
let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));
// `a^b.pdf` names one book per row, both in the folder of the path.
let dir = crate::temp_dir::TempDir::new("pdfbook");
let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") }));
let r = r.unwrap();
let books = r["books"].as_array().unwrap();
assert_eq!(books.len(), 2);
assert_eq!(books[0]["pages"], 3);
assert_eq!(books[1]["pages"], 1);
let first = temp("a.pdf");
let first = dir.join("a.pdf");
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
assert!(a4 < a3, "frames go left to right: {text}");
assert!(text.contains("\"landscape\":true"));
let _ = std::fs::remove_file(first);
let _ = std::fs::remove_file(temp("b.pdf"));
assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first");
// No frames: an error, not a panic.
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
}
+204 -12
View File
@@ -420,11 +420,20 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
Ok(n)
}
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
/// Put saved bytes where the user gets them: natively the file at `path`; on the web, which has
/// no file system, a browser download named after `path` via [`deliver`].
///
/// Natively the file is replaced atomically ([`crate::atomic_write`]: a fresh temporary file
/// beside it, renamed over it), so a crash leaves either the old file or the new one, never a
/// truncated one. Saving over a drawing first keeps the previous file as `<name>.bak` (ISAVEBAK,
/// #540).
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
if is_drawing_file(path) && crate::autosave::isavebak() {
backup(std::path::Path::new(path))
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
}
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
@@ -437,6 +446,180 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
}
}
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
path.with_extension("bak")
}
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a
/// good `.bak`.
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
if dwg || cadcraft_dxf::is_binary(bytes) {
return true;
}
// ASCII DXF: optional comments (999), then group 0 SECTION.
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
loop {
match (lines.next(), lines.next()) {
(Some("999"), Some(_)) => continue,
(Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"),
_ => return false,
}
}
}
/// How much of a drawing's end [`looks_complete`] reads.
#[cfg(not(target_arch = "wasm32"))]
const TAIL_BYTES: u64 = 256;
/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them
/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it
/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its
/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut
/// off by a failed save or another program doesn't replace a good backup.
pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit());
if dwg {
// Too short for any DWG's header: cut off, not unknown.
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
}
if cadcraft_dxf::is_binary(head) {
return Some(tail.ends_with(b"EOF\0"));
}
let text = String::from_utf8_lossy(tail);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev();
Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF")))
}
/// [`completeness`] known to be whole.
pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool {
completeness(head, tail, len) != Some(false)
}
/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for
/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in
/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section
/// the locator records give. `None` for another layout (2007, or before R13).
fn dwg_complete(head: &[u8], len: u64) -> Option<bool> {
let version = head.get(..6)?;
match version {
b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => {
// The header at 0x80 is XORed with the generator the specification gives.
let enc = head.get(0x80..0x80 + 0x6C)?;
let mut seed: u32 = 1;
let h: Vec<u8> = enc
.iter()
.map(|b| {
seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3);
b ^ (seed >> 16) as u8
})
.collect();
if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) {
return Some(false);
}
let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?);
Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end))
}
b"AC1012" | b"AC1014" | b"AC1015" => {
let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?);
if !(1..=16).contains(&n) {
return Some(false);
}
let mut end = 0u64;
for i in 0..n as usize {
let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?;
let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?));
let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?));
end = end.max(seek + size);
}
Some(len >= end)
}
_ => None,
}
}
/// A file's first bytes, last bytes and length, left positioned just after the first bytes.
#[cfg(not(target_arch = "wasm32"))]
fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec<u8>, Vec<u8>, u64)> {
use std::io::{Read, Seek, SeekFrom};
let mut head = Vec::with_capacity(4096);
(&mut *src).take(4096).read_to_end(&mut head)?;
// Its end too (a DXF's EOF), then on from where the start left off for the copy.
let len = src.metadata()?.len();
let mut tail = Vec::new();
src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
(&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?;
src.seek(SeekFrom::Start(head.len() as u64))?;
Ok((head, tail, len))
}
/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a
/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it
/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong
/// about a real file and this save replaces the only copy.
#[cfg(not(target_arch = "wasm32"))]
fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool {
(looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists)
}
/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that
/// must not promise a backup the save won't write.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_keeps_current(path: &std::path::Path) -> bool {
let Ok(mut src) = std::fs::File::open(path) else { return false };
crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists()))
}
#[cfg(target_arch = "wasm32")]
pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone.
#[cfg(not(target_arch = "wasm32"))]
fn backup(path: &std::path::Path) -> std::io::Result<()> {
let mut src = match std::fs::File::open(path) {
Ok(f) if f.metadata()?.is_file() => f,
Ok(_) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let (head, tail, len) = ends(&mut src)?;
let bak = backup_path(path);
if !backup_replaced(&head, &tail, len, bak.exists()) {
log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display());
return Ok(());
}
// The backup is no more readable than the drawing, nor than the .bak it replaces (a private
// 0600 drawing or .bak stays private). The mode is set as the new file is created, so a
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
};
#[cfg(not(unix))]
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
}
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
@@ -564,6 +747,17 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
}
/// What saving the active drawing to `path` would leave out, before anything is written: empty
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
/// reports the same list afterwards to callers that never get a dialog.
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
if !drops_unknown(path) {
return Default::default();
}
let version = drawing_version(s, path);
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
}
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
/// bridge drops types it doesn't know); DXF writes them back as read.
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
@@ -652,20 +846,16 @@ mod tests {
use serde_json::json;
use crate::Session;
use crate::temp_dir::TempDir;
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
fn temp_script(name: &str, text: &str) -> String {
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
std::fs::write(&path, text).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn script_command_runs_a_scr_file() {
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
let dir = TempDir::new("script");
let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
let mut s = Session::new();
let r = s.execute("script", &json!({ "path": path })).unwrap();
assert_eq!(r["lines"], 3);
@@ -683,8 +873,9 @@ mod tests {
/// A script that runs itself must be refused, not recurse until the stack dies.
#[test]
fn a_script_cannot_start_a_script() {
let inner = temp_script("inner.scr", "circle 0,0 1\n");
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
let dir = TempDir::new("script");
let inner = dir.file("inner.scr", "circle 0,0 1\n");
let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
let mut s = Session::new();
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
assert!(e.contains("already running"), "{e}");
@@ -692,7 +883,8 @@ mod tests {
#[test]
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
let good = temp_script("typed.scr", "circle 0,0 1\n");
let dir = TempDir::new("script");
let good = dir.file("typed.scr", "circle 0,0 1\n");
let mut s = Session::new();
s.cmdline("script").unwrap();
assert!(s.running.is_some(), "SCRIPT asks for the file name");
+100 -18
View File
@@ -550,6 +550,9 @@ struct HatchM {
/// Loops by pick (one hatch each when separate).
loops: Vec<Vec<HatchLoop>>,
asking: Option<HatchAsk>,
/// The pattern was asked by the Pattern option: its scale and angle follow, as at AutoCAD's
/// -HATCH Properties.
properties: bool,
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
selecting: bool,
}
@@ -561,6 +564,7 @@ impl HatchM {
settings: HatchSettings::from(s, &Value::Null, gradient),
loops: Vec::new(),
asking: None,
properties: false,
selecting: s.last_used.hatch_select,
}
}
@@ -582,11 +586,13 @@ impl HatchM {
h.set_i64("HPORIGINMODE", mode.index());
Ok(())
}
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
/// Take an answer to sub-prompt `a`; false when it was refused.
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<bool> {
match a {
HatchAsk::Pattern => {
let up = match patfile::typed_file(s, t.trim()) {
Some(n) if n.is_empty() => return Ok(()),
// Nothing loaded from it: the pattern is asked again.
Some(n) if n.is_empty() => return Ok(false),
Some(n) => n,
None => t.trim().to_ascii_uppercase(),
};
@@ -595,20 +601,19 @@ impl HatchM {
s.doc_mut()?.header.set_str("HPNAME", &up);
} else {
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
return Ok(false);
}
}
HatchAsk::Scale => {
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
self.settings.scale = v;
s.doc_mut()?.header.set_f64("HPSCALE", v);
}
let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) else { return Ok(false) };
self.settings.scale = v;
s.doc_mut()?.header.set_f64("HPSCALE", v);
}
HatchAsk::Angle => {
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
self.settings.angle = a;
s.doc_mut()?.header.set_f64("HPANG", a);
}
let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) else { return Ok(false) };
self.settings.angle = a;
s.doc_mut()?.header.set_f64("HPANG", a);
}
HatchAsk::Origin => {
let mode = match t {
@@ -622,7 +627,22 @@ impl HatchM {
self.set_origin(s, None, mode)?;
}
}
Ok(())
Ok(true)
}
/// The sub-prompt that follows `a` (`taken`: its answer was taken). The Pattern option goes
/// on, as AutoCAD 2024's -HATCH Properties does, from the pattern name to its scale and then
/// its angle (a solid fill has neither), and asks again for a pattern or a value it refused.
fn after(&self, a: HatchAsk, taken: bool) -> Option<HatchAsk> {
if !self.properties {
return None;
}
match a {
_ if !taken => Some(a),
HatchAsk::Pattern if self.settings.pattern != "SOLID" => Some(HatchAsk::Scale),
HatchAsk::Scale => Some(HatchAsk::Angle),
_ => None,
}
}
}
@@ -643,9 +663,10 @@ impl Interactive for HatchM {
// The sub-prompts offer the current value: Enter keeps it.
match self.asking {
Some(HatchAsk::Pattern) => {
return Prompt::new("Enter a pattern name", Accept::TEXT)
let p = Prompt::new("Enter a pattern name", Accept::TEXT)
.default(self.settings.pattern.clone())
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
return if self.gradient { p } else { p.kw(&["Solid"]) };
}
Some(HatchAsk::Scale) => {
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
@@ -686,11 +707,19 @@ impl Interactive for HatchM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking.take() {
match &i {
Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?,
let taken = match &i {
Input::Point(p) if a == HatchAsk::Origin => {
self.set_origin(s, Some(*p), OriginMode::Current)?;
true
}
Input::Keyword(k) if a == HatchAsk::Pattern && k == "Solid" => self.answer(s, a, "SOLID")?,
Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?,
_ => {}
}
// Enter keeps the current value; a point or a pick answers none of these prompts.
Input::Enter => true,
_ => false,
};
self.asking = self.after(a, taken);
self.properties &= self.asking.is_some();
return Ok(Step::Continue);
}
match i {
@@ -723,7 +752,10 @@ impl Interactive for HatchM {
"Select objects" => self.set_mode(s, true),
"picK internal point" => self.set_mode(s, false),
"Undo" => self.loops.clear(),
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
"Pattern" | "seTtings" => {
self.asking = Some(HatchAsk::Pattern);
self.properties = !self.gradient;
}
"sCale" => self.asking = Some(HatchAsk::Scale),
"Angle" => self.asking = Some(HatchAsk::Angle),
"Origin" => self.asking = Some(HatchAsk::Origin),
@@ -738,7 +770,7 @@ impl Interactive for HatchM {
}
// A pattern chosen in the picker while boundaries are picked.
Input::Text(t) if !self.gradient => {
self.answer(s, HatchAsk::Pattern, &t)?;
let _ = self.answer(s, HatchAsk::Pattern, &t)?;
Ok(Step::Continue)
}
// Enter ends boundary picking in either mode and creates the hatch.
@@ -838,6 +870,9 @@ mod tests {
s.input(Input::Keyword("Pattern".into())).unwrap();
assert!(s.current_prompt().unwrap().picker.is_some());
s.input(Input::Text("BRICK".into())).unwrap();
// Its scale and angle follow; Enter keeps them.
s.input(Input::Enter).unwrap();
s.input(Input::Enter).unwrap();
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
@@ -851,6 +886,53 @@ mod tests {
assert!(s.current_prompt().unwrap().picker.is_none());
}
/// -HATCH's Properties as AutoCAD 2024 takes them from AutoLISP's `command` (accoreconsole, the
/// hatch read back with `entget`): a pattern name, then its scale and angle (Enter keeps them);
/// Solid (`S`, `_S` or SOLID) asks neither; an unknown name is asked again.
#[test]
fn hatch_properties_ask_pattern_scale_and_angle_as_autocad() {
// (properties given, pattern, solid, scale, angle in degrees)
let cases: [(&str, &str, bool, f64, f64); 7] = [
("\"ANSI31\" 2 45 \"_S\" (entlast) \"\"", "ANSI31", false, 2.0, 45.0),
("\"_S\" \"_S\" (entlast) \"\"", "SOLID", true, 1.0, 0.0),
("\"ANSI37\" 0.5 30 '(5.0 5.0)", "ANSI37", false, 0.5, 30.0),
("\"ANSI31\" \"\" \"\" '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
("\"S\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
("\"SOLID\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
("\"XYZ\" \"ANSI31\" 1 0 '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
];
for (props, pattern, solid, scale, angle) in cases {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [20, 15], "radius": 8 })).unwrap();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
s.cmdline("(setvar \"HPNAME\" \"ANSI31\")").unwrap();
s.cmdline("(setvar \"HPSCALE\" 1.0)").unwrap();
s.cmdline("(setvar \"HPANG\" 0.0)").unwrap();
s.cmdline(&format!("(command \"_.-HATCH\" \"_P\" {props} \"\")")).unwrap();
assert!(s.running.is_none(), "{props}: {}", s.prompt_text());
let h = only_hatch(&s).1;
assert!(
h.pattern == pattern && h.solid == solid && (h.scale - scale).abs() < 1e-12 && (h.angle - angle.to_radians()).abs() < 1e-12,
"{props}: {} {} {} {}",
h.pattern,
h.solid,
h.scale,
h.angle.to_degrees()
);
}
// An object (an entity name from LISP) answers none of these prompts: each is asked again.
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
let h = s.doc().unwrap().model.handles()[0];
s.cmdline("(command \"_.-HATCH\" \"_P\")").unwrap();
s.input(crate::Input::Pick(vec![h])).unwrap();
assert!(s.prompt_text().contains("Enter a pattern name"), "{}", s.prompt_text());
s.cmdline("ANSI31").unwrap();
s.input(crate::Input::Pick(vec![h])).unwrap();
assert!(s.prompt_text().contains("Specify a scale for the hatch pattern"), "{}", s.prompt_text());
s.cancel();
}
#[test]
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
let mut s = Session::new();
+5 -9
View File
@@ -840,11 +840,9 @@ impl Interactive for DistM {
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
}
Input::Keyword(k) if k == "Undo" => {
if pts.len() > 1 {
self.arcs.undo(pts, &mut self.bulges);
self.sources.pop();
}
Input::Keyword(k) if k == "Undo" && pts.len() > 1 => {
self.arcs.undo(pts, &mut self.bulges);
self.sources.pop();
}
Input::Keyword(k) if k == "Length" => self.length = true,
Input::Keyword(k) if k == "Total" => return self.total(s, false),
@@ -852,10 +850,8 @@ impl Interactive for DistM {
self.arcs.close(pts, &mut self.bulges)?;
return self.total(s, true);
}
Input::Keyword(k) => {
if !self.arcs.keyword(&k, pts) {
s.echo(format!("{k}: not available yet"));
}
Input::Keyword(k) if !self.arcs.keyword(&k, pts) => {
s.echo(format!("{k}: not available yet"));
}
Input::Enter => return self.total(s, false),
_ => {}
+1
View File
@@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn);
}
}
merge::rename_in_viewports(d, &old, &nn);
if cur.eq_ignore_ascii_case(&old) {
d.header.set_str("CLAYER", &nn);
}
+33
View File
@@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet<String>) {
}
}
/// Point every layout viewport's frozen list and colour overrides at a renamed layer.
pub(super) fn rename_in_viewports(d: &mut Drawing, old: &str, new: &str) {
for layout in &mut d.layouts {
let hs: Vec<Handle> = layout
.entities
.iter()
.filter(|e| match &e.kind {
EntityKind::Viewport(v) => {
v.frozen_layers.iter().any(|l| l.eq_ignore_ascii_case(old)) || v.layer_colors.iter().any(|(l, _)| l.eq_ignore_ascii_case(old))
}
_ => false,
})
.map(|e| e.handle)
.collect();
for h in hs {
layout.entities.modify(h, |e| {
if let EntityKind::Viewport(v) = &mut e.kind {
for l in &mut v.frozen_layers {
if l.eq_ignore_ascii_case(old) {
*l = new.to_string();
}
}
for (l, _) in &mut v.layer_colors {
if l.eq_ignore_ascii_case(old) {
*l = new.to_string();
}
}
}
});
}
}
}
fn lower_set(names: &[String]) -> HashSet<String> {
names.iter().map(|n| n.to_ascii_lowercase()).collect()
}
+1 -1
View File
@@ -250,7 +250,7 @@ impl Interactive for BreakM {
(None, Input::Point(p)) => {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
match crate::select::pick(s.doc()?, &space, p, ap) {
match s.pick_object(&space, p, ap)? {
Some(h) => {
self.obj = Some((h, p));
s.set_selection(vec![h]);
+6 -2
View File
@@ -118,7 +118,11 @@ pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result<Value> {
}
(None, None) => {
let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0));
ChamferCut::Dist(d1, f64_or(p, "d2", d1))
let d2 = f64_or(p, "d2", d1);
if !(d1.is_finite() && d1 >= 0.0 && d2.is_finite() && d2 >= 0.0) {
return Err(bad("chamfer", "`d1` and `d2` must be >= 0"));
}
ChamferCut::Dist(d1, d2)
}
_ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")),
};
@@ -338,7 +342,7 @@ impl Interactive for FilletM {
Input::Point(p) => {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
let Some(h) = s.pick_object(&space, p, ap)? else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
+4 -1
View File
@@ -197,6 +197,9 @@ fn join_point(a: &Segment, b: &Segment) -> Option<Vec2> {
pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?;
let dist = f64_req("offset", p, "distance")?;
if dist == 0.0 {
return Err(bad("offset", "Requires a positive distance."));
}
let side = point_req("offset", p, "side")?;
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?;
let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?;
@@ -442,7 +445,7 @@ impl Interactive for OffsetM {
(None, Input::Point(p)) => {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
match crate::select::pick(s.doc()?, &space, p, ap) {
match s.pick_object(&space, p, ap)? {
Some(h) => {
self.picked = Some(h);
s.set_selection(vec![h]);
+4 -1
View File
@@ -66,7 +66,10 @@ pub(super) fn run_move(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let d = delta_of("copy", p)?;
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000);
let n = match p.get("count") {
None => 1,
Some(v) => v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad("copy", "`count` must be an integer from 1 to 10000"))?,
};
let mut out = Vec::new();
for k in 1..=n {
out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex()));
+1
View File
@@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> {
}
}
mod pedit_multi;
mod pedit_vertex;
// =====================================================================================
+48 -4
View File
@@ -164,7 +164,7 @@ pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f6
Ok(joined.len())
}
fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
pub(super) fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
let e = curves::entity(s, h)?;
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
@@ -287,6 +287,13 @@ pub(super) enum PPhase {
/// The second marker of Break / Straighten.
VMark,
VTangent,
/// Multiple ([`super::pedit_multi`]): selecting the objects, converting lines and arcs, the
/// options, the width for all, Join's fuzz distance.
MSelect,
MConvert,
MMain,
MWidth,
MFuzz,
}
#[derive(Default)]
@@ -305,6 +312,34 @@ pub(super) struct PeditM {
pub(super) tangents: Vec<(Vec2, Vec2)>,
/// Pieces split off by Break, with the history length after it (erased again by Undo).
pub(super) split_off: Vec<(usize, Vec<Handle>)>,
/// Multiple: the polylines being edited, their selection, and the drawing before each option
/// (with those polylines and their Decurve record) for Undo.
pub(super) multi: Vec<Handle>,
pub(super) msel: SelectPhase,
pub(super) mundo: Vec<(cadcraft_doc::Drawing, Vec<Handle>, std::collections::HashMap<Handle, Vec<PolyVertex>>)>,
/// Multiple: each polyline's vertices before its first Fit or Spline, which Decurve restores.
pub(super) morig: std::collections::HashMap<Handle, Vec<PolyVertex>>,
}
/// A Yes/No answer: Enter is Yes, anything but Y/Yes/N/No is refused so the question is asked again.
pub(super) fn yes_no(i: &Input) -> Result<bool> {
match i {
Input::Enter => Ok(true),
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => Ok(true),
"n" | "no" => Ok(false),
_ => Err(other("Enter Yes or No.")),
},
_ => Err(other("Enter Yes or No.")),
}
}
/// Width sets one width for the whole polyline, so the vertices Decurve gives back take it too.
pub(super) fn clear_widths(vs: &mut [PolyVertex]) {
for v in vs {
v.start_width = 0.0;
v.end_width = 0.0;
}
}
impl PeditM {
@@ -339,6 +374,10 @@ impl Interactive for PeditM {
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let sel = s.selection();
// Several objects selected first: straight into Multiple, as in AutoCAD.
if sel.len() > 1 && s.settings.pickfirst {
return self.start_multi(s);
}
if let [h] = sel.as_slice() {
let k = curves::entity(s, *h)?.kind;
match k {
@@ -358,6 +397,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::Convert => {
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
@@ -398,6 +438,7 @@ impl Interactive for PeditM {
_ => None,
};
match self.phase {
PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_input(s, i),
PPhase::Select => match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
@@ -417,13 +458,13 @@ impl Interactive for PeditM {
s.set_selection(vec![h]);
Ok(Step::Continue)
}
Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")),
Input::Keyword(k) if k.eq_ignore_ascii_case("multiple") || k.eq_ignore_ascii_case("m") => self.start_multi(s),
Input::Keyword(_) => Err(other("Invalid option keyword.")),
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
},
PPhase::Convert => {
let no = kw(&i).is_some_and(|k| k.starts_with('n'));
if no {
if !yes_no(&i)? {
return Ok(Step::Done);
}
let h = self.h.ok_or_else(|| other("No object."))?;
@@ -487,6 +528,9 @@ impl Interactive for PeditM {
if let Input::Text(t) = &i {
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
self.apply(s, "width", json!({ "width": w }))?;
if let Some(o) = &mut self.original {
clear_widths(o);
}
}
self.phase = PPhase::Main;
Ok(Step::Continue)
@@ -0,0 +1,235 @@
//! PEDIT Multiple: edit several polylines (and lines and arcs turned into polylines) at once, as
//! AutoCAD does after `M` at "Select polyline". Join joins the selected objects with each other.
use cadcraft_doc::{Drawing, EntityKind, Handle};
use cadcraft_geom::PolyVertex;
use serde_json::json;
use super::super::curves;
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::pedit::{PPhase, PeditM, clear_widths, pedit_apply, pedit_join, yes_no};
use super::{as_lwpoly, other, set_kind};
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// Most option steps Undo can take back.
const MAX_UNDO: usize = 64;
/// The options of the Multiple menu.
const OPTIONS: [&str; 10] = ["Close", "Open", "Join", "Width", "Fit", "Spline", "Decurve", "Ltype gen", "Reverse", "Undo"];
impl PeditM {
/// Start Multiple: select the objects to edit, or take the ones selected first.
pub(super) fn start_multi(&mut self, s: &mut Session) -> Result<Step> {
self.phase = PPhase::MSelect;
self.msel = SelectPhase::begin(s);
if self.msel.done {
let hs = std::mem::take(&mut self.msel.picked);
return self.take_multi(s, hs);
}
s.set_selection(Vec::new());
Ok(Step::Continue)
}
/// The objects selected for Multiple: polylines, lines and arcs not on locked layers.
fn take_multi(&mut self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
let d = s.doc()?;
let kinds = |h: &Handle| d.entity(*h).map(|e| e.kind.clone());
let editable: Vec<Handle> = hs
.iter()
.copied()
.filter(|h| matches!(kinds(h), Some(EntityKind::LwPolyline(_) | EntityKind::Line(_) | EntityKind::Arc(_))))
.filter(|h| !curves::is_locked(s, *h))
.collect();
let convert = editable.iter().any(|h| matches!(kinds(h), Some(EntityKind::Line(_) | EntityKind::Arc(_))));
self.multi = editable;
if self.multi.is_empty() {
s.echo("No polylines, lines or arcs selected.");
return Ok(Step::Done);
}
self.phase = if convert { PPhase::MConvert } else { PPhase::MMain };
s.set_selection(self.multi.clone());
Ok(Step::Continue)
}
/// The prompt of a Multiple phase.
pub(super) fn multi_prompt(&self) -> Prompt {
match self.phase {
PPhase::MConvert => Prompt::new("Convert Lines and Arcs to polylines", curves::KW).kw(&["Yes", "No"]).default("Y"),
PPhase::MMain => Prompt::new("Enter an option", curves::KW).kw(&OPTIONS),
PPhase::MWidth => Prompt::new("Specify new width for all segments", Accept::NUMBER),
PPhase::MFuzz => Prompt::new("Enter fuzz distance", Accept::NUMBER).default("0.0000"),
_ => self.msel.prompt(),
}
}
/// Input in a Multiple phase.
pub(super) fn multi_input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let kw = |i: &Input| match i {
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
_ => None,
};
match self.phase {
PPhase::MSelect => match self.msel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.take_multi(s, hs),
},
PPhase::MConvert => {
let no = !yes_no(&i)?;
let mut kept = Vec::new();
for h in std::mem::take(&mut self.multi) {
let kind = curves::entity(s, h)?.kind;
match kind {
EntityKind::LwPolyline(_) => kept.push(h),
_ if no => {}
k => {
if let Some(pl) = as_lwpoly(&k) {
set_kind(s, h, EntityKind::LwPolyline(pl))?;
kept.push(h);
}
}
}
}
self.multi = kept;
if self.multi.is_empty() {
s.echo("No polylines to edit.");
return Ok(Step::Done);
}
s.set_selection(self.multi.clone());
self.phase = PPhase::MMain;
Ok(Step::Continue)
}
PPhase::MMain => {
let Some(k) = kw(&i) else {
if i == Input::Enter {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
return Ok(Step::Continue);
};
match k.as_str() {
"close" | "c" => self.apply_all(s, "close", &json!({}))?,
"open" | "o" => self.apply_all(s, "open", &json!({}))?,
"join" | "j" => self.phase = PPhase::MFuzz,
"width" | "w" => self.phase = PPhase::MWidth,
"fit" | "f" => self.curve_all(s, "fit")?,
"spline" | "s" => self.curve_all(s, "spline")?,
"decurve" | "d" => self.decurve_all(s)?,
"ltype gen" | "l" => {
// One setting for all: the opposite of the first one's.
let on = self.multi.first().and_then(|h| curves::entity(s, *h).ok()).is_some_and(|e| match e.kind {
EntityKind::LwPolyline(p) => !p.plinegen,
_ => true,
});
self.apply_all(s, "ltypegen", &json!({ "on": on }))?;
}
"reverse" | "r" => self.apply_all(s, "reverse", &json!({}))?,
"undo" | "u" => match self.mundo.pop() {
Some((d, multi, morig)) => {
*s.doc_mut()? = d;
self.multi = multi;
self.morig = morig;
s.set_selection(self.multi.clone());
}
None => s.echo("Nothing to undo."),
},
_ => return Err(other("Invalid option keyword.")),
}
Ok(Step::Continue)
}
PPhase::MWidth => {
if let Input::Text(t) = &i {
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
self.apply_all(s, "width", &json!({ "width": w }))?;
self.morig.values_mut().for_each(|o| clear_widths(o));
}
self.phase = PPhase::MMain;
Ok(Step::Continue)
}
PPhase::MFuzz => {
let fuzz = match &i {
Input::Text(t) => number(t).filter(|f| *f >= 0.0 && f.is_finite()).ok_or_else(|| other("Requires a non-negative distance."))?,
_ => 0.0,
};
self.join_all(s, fuzz)?;
self.phase = PPhase::MMain;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
}
}
/// Keep the drawing as it is for Undo (and the polylines being edited, with their Decurve
/// record).
fn remember(&mut self, s: &Session) -> Result<()> {
let d: Drawing = s.doc()?.clone();
if self.mundo.len() >= MAX_UNDO {
self.mundo.remove(0);
}
self.mundo.push((d, self.multi.clone(), self.morig.clone()));
Ok(())
}
/// Apply option `opt` to every polyline being edited.
fn apply_all(&mut self, s: &mut Session, opt: &str, p: &serde_json::Value) -> Result<()> {
self.remember(s)?;
self.multi.retain(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some()));
for h in self.multi.clone() {
pedit_apply(s, h, opt, p)?;
}
Ok(())
}
/// Fit or Spline all of them, first keeping each one's vertices for Decurve.
fn curve_all(&mut self, s: &mut Session, opt: &str) -> Result<()> {
for h in &self.multi {
if !self.morig.contains_key(h)
&& let Ok(EntityKind::LwPolyline(p)) = curves::entity(s, *h).map(|e| e.kind)
{
self.morig.insert(*h, p.vertices);
}
}
self.apply_all(s, opt, &json!({}))
}
/// Decurve all of them: one fitted or splined here gets back its vertices from before (as
/// single-polyline PEDIT does), the others lose their arcs.
fn decurve_all(&mut self, s: &mut Session) -> Result<()> {
self.apply_all(s, "decurve", &json!({}))?;
for h in self.multi.clone() {
if let Some(orig) = self.morig.get(&h)
&& let Ok(EntityKind::LwPolyline(mut p)) = curves::entity(s, h).map(|e| e.kind)
{
p.vertices = orig.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect();
set_kind(s, h, EntityKind::LwPolyline(p))?;
}
}
Ok(())
}
/// Join the polylines being edited with each other, ends within `fuzz`: each one still open
/// takes the others that meet it, and those go into it.
fn join_all(&mut self, s: &mut Session, fuzz: f64) -> Result<()> {
self.remember(s)?;
let mut rest: Vec<Handle> = self.multi.iter().copied().filter(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())).collect();
let before = rest.len();
let mut kept = Vec::new();
while !rest.is_empty() {
let h = rest.remove(0);
let open = matches!(curves::entity(s, h)?.kind, EntityKind::LwPolyline(p) if !p.closed);
if open {
// A polyline that took on segments no longer goes back past the Join on Decurve.
if pedit_join(s, h, &rest, fuzz)? > 0 {
self.morig.remove(&h);
}
rest.retain(|o| s.doc().is_ok_and(|d| d.entity(*o).is_some()));
}
kept.push(h);
}
let added = before - kept.len();
s.echo(format!("{added} segments added to polyline"));
self.multi = kept;
s.set_selection(self.multi.clone());
Ok(())
}
}
+31 -3
View File
@@ -1,5 +1,7 @@
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME.
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
//! RENAME.
mod chprop;
mod matchprop;
mod rename;
@@ -18,6 +20,9 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
.menu(&["Modify", "Match Properties"])
.alias(&["ma", "painter"])
@@ -153,7 +158,9 @@ fn run_get(s: &mut Session, p: &Value) -> Result<Value> {
}
fn parse_color(v: &Value) -> Option<Color> {
v.as_str().and_then(Color::parse).or_else(|| v.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci))
v.as_str()
.and_then(Color::parse)
.or_else(|| v.as_i64().filter(|i| (0..=256).contains(i)).and_then(|i| i16::try_from(i).ok()).map(Color::from_aci))
}
/// A transparency as JSON, in the form `properties.set` takes: "ByLayer", "ByBlock" or a percentage.
@@ -645,9 +652,12 @@ impl Interactive for ThicknessM {
}
}
/// The unit variables (lower-case name, lowest, highest value) shared by UNITS and SETVAR.
pub(crate) const UNIT_RANGES: [(&str, i64, i64); 5] = [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)];
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
for (k, lo, hi) in UNIT_RANGES {
if let Some(v) = p.get(k).and_then(Value::as_i64) {
d.header.set_i64(&k.to_ascii_uppercase(), v.clamp(lo, hi));
}
@@ -1218,6 +1228,24 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn properties_set_rejects_out_of_range_colour() {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap();
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
s.execute("properties.set", &json!({ "handles": [h], "color": 1 })).unwrap();
let col = |s: &Session| s.doc().unwrap().model.last().unwrap().common.color;
for bad in [300, -5, 257] {
let e = s.execute("properties.set", &json!({ "handles": [h], "color": bad })).unwrap_err();
assert!(e.to_string().contains("bad colour"), "{e}");
assert_eq!(col(&s), Color::Index(1));
}
s.execute("properties.set", &json!({ "handles": [h], "color": 256 })).unwrap();
assert_eq!(col(&s), Color::ByLayer);
s.execute("properties.set", &json!({ "handles": [h], "color": 0 })).unwrap();
assert_eq!(col(&s), Color::ByBlock);
}
#[test]
fn properties_set_rejects_unloaded_linetype() {
let mut s = Session::new();
+338
View File
@@ -0,0 +1,338 @@
//! CHPROP: change object properties at the command line, as AutoCAD 2024 asks for them.
//!
//! "Enter property to change [Color/LAyer/LType/ltScale/LWeight/Thickness/TRansparency]" comes
//! back after each answer until Enter; each property asks for its value and applies it at once
//! (through `properties.set`). Measured from AutoLISP's `command` (accoreconsole, every object read
//! back with `entget`):
//! - a colour is 1–255, a name (red … white), BYLAYER or BYBLOCK, or Truecolor and then "r,g,b";
//! 0, 256 and 300 are refused with "Invalid color number.";
//! - a layer must exist: `Cannot find layer "KHONGCO".`;
//! - a linetype not loaded yet is loaded from the standard ones (PHANTOM2);
//! - a lineweight goes to the nearest standard one, with a note: 0.33 gives "Lineweight rounded to
//! nearest valid value of 0.35 mm";
//! - `L` alone is "Ambiguous response, please clarify...";
//! - objects on locked layers are left out at "Select objects", which says how many after what was
//! found ("1 was on a locked layer.", "2 were on a locked layer."); with none left, Enter ends
//! the command.
use std::collections::HashSet;
use cadcraft_color::Color;
use cadcraft_doc::{Entity, Handle, Lineweight};
use serde_json::{Value, json};
use super::super::curves::{KW, is_locked};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::targets;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
/// What CHPROP asks for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Ask {
Property,
Color,
TrueColor,
Layer,
Linetype,
LtScale,
Lineweight,
Thickness,
Transparency,
}
pub(super) struct ChpropM {
select: SelectPhase,
handles: Vec<Handle>,
/// `None` while objects are selected.
ask: Option<Ask>,
}
impl ChpropM {
pub(super) fn new() -> Self {
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None }
}
/// The selection's common value of a property, or "varies".
fn current(&self, s: &Session, f: impl Fn(&Entity) -> String) -> String {
let Ok(d) = s.doc() else { return String::new() };
let mut vals = self.handles.iter().filter_map(|h| d.entity(*h)).map(|e| f(e));
let first = vals.next().unwrap_or_default();
if vals.all(|v| v == first) { first } else { "varies".into() }
}
/// Apply `props` to the selection through `properties.set`.
fn apply(&self, s: &mut Session, props: Value) -> Result<()> {
let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::<Vec<_>>() });
if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) {
p.extend(props);
}
super::run_set(s, &p).map(|_| ())
}
/// Take an answer to `ask`; `false` when it was refused (the prompt comes again).
fn answer(&mut self, s: &mut Session, ask: Ask, t: &str) -> Result<bool> {
let t = t.trim();
let word = t.strip_prefix('_').unwrap_or(t);
match ask {
Ask::Color => match color(word) {
Some(c) => self.apply(s, json!({ "color": color_json(c) }))?,
None => {
s.echo("Invalid color number.");
return Ok(false);
}
},
Ask::TrueColor => match Color::parse(word) {
Some(c @ Color::True(_)) => self.apply(s, json!({ "color": color_json(c) }))?,
_ => {
s.echo("Requires three integers from 0 to 255, separated by commas.");
return Ok(false);
}
},
Ask::Layer => match s.doc()?.layer(word).map(|l| l.name.clone()) {
Some(name) => self.apply(s, json!({ "layer": name }))?,
None => {
s.echo(format!("Cannot find layer \"{word}\"."));
return Ok(false);
}
},
Ask::Linetype => {
let by = ["ByLayer", "ByBlock"].into_iter().find(|b| b.eq_ignore_ascii_case(word));
let name = match by {
Some(b) => Some(b.to_string()),
None => {
// Not loaded yet: load it from the standard linetypes, as AutoCAD does.
if s.doc()?.linetype(word).is_none() {
let _ = super::run_linetype(s, &json!({ "load": word }));
}
s.doc()?.linetype(word).map(|l| l.name.clone())
}
};
match name {
Some(n) => self.apply(s, json!({ "linetype": n }))?,
None => {
s.echo(format!("Cannot find linetype \"{word}\"."));
return Ok(false);
}
}
}
Ask::LtScale | Ask::Thickness => {
let v = number(word).filter(|v| v.is_finite() && (ask == Ask::Thickness || *v > 0.0));
match v {
Some(v) => self.apply(s, if ask == Ask::LtScale { json!({ "ltscale": v }) } else { json!({ "thickness": v }) })?,
None => {
s.echo(if ask == Ask::LtScale { "Requires a positive number." } else { "Requires a number." });
return Ok(false);
}
}
}
Ask::Lineweight => match lineweight(word) {
Some((lw, rounded)) => {
if let (true, Lineweight::Mm100(v)) = (rounded, lw) {
s.echo(format!("Lineweight rounded to nearest valid value of {:.2} mm", f64::from(v) / 100.0));
}
let v = match lw {
Lineweight::Mm100(v) => json!(f64::from(v) / 100.0),
Lineweight::ByLayer => json!("ByLayer"),
Lineweight::ByBlock => json!("ByBlock"),
Lineweight::Default => json!("Default"),
};
self.apply(s, json!({ "lineweight": v }))?;
}
None => {
s.echo("Invalid lineweight.");
return Ok(false);
}
},
Ask::Transparency => {
if self.apply(s, json!({ "transparency": word })).is_err() {
s.echo("Requires ByLayer, ByBlock or an integer from 0 to 90.");
return Ok(false);
}
}
Ask::Property => {}
}
Ok(true)
}
}
/// The objects among `hs` on locked layers.
fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
hs.iter().copied().filter(|h| is_locked(s, *h)).collect()
}
fn say_locked(s: &mut Session, n: usize) {
match n {
0 => {}
1 => s.echo("1 was on a locked layer."),
n => s.echo(format!("{n} were on a locked layer.")),
}
}
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
/// command leaves out too; "locked" says how many were.
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let locked = locked(s, &hs);
if locked.is_empty() {
return super::run_set(s, p).map(|r| with_locked(r, 0));
}
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
let r = if free.is_empty() {
json!({ "changed": 0 })
} else {
let mut q = p.as_object().cloned().unwrap_or_default();
q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::<Vec<_>>()));
super::run_set(s, &Value::Object(q))?
};
Ok(with_locked(r, locked.len()))
}
/// `r` with how many objects were left out on locked layers.
fn with_locked(mut r: Value, n: usize) -> Value {
if let Some(o) = r.as_object_mut() {
o.insert("locked".into(), json!(n));
}
r
}
/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes).
fn color(t: &str) -> Option<Color> {
if t.bytes().all(|b| b.is_ascii_digit()) {
return t.parse::<u16>().ok().filter(|i| (1..=255).contains(i)).and_then(|i| u8::try_from(i).ok()).map(Color::Index);
}
Color::parse(t).filter(|c| !matches!(c, Color::True(_)))
}
fn color_json(c: Color) -> Value {
match c {
Color::Index(i) => json!(i),
Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)),
other => json!(other.name()),
}
}
fn lineweight_name(lw: Lineweight) -> String {
match lw {
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
Lineweight::ByLayer => "ByLayer".into(),
Lineweight::ByBlock => "ByBlock".into(),
Lineweight::Default => "Default".into(),
}
}
/// A lineweight typed in millimetres (the nearest standard one, and whether that rounded it), or
/// ByLayer, ByBlock or Default.
fn lineweight(t: &str) -> Option<(Lineweight, bool)> {
match t.to_ascii_lowercase().as_str() {
"bylayer" => return Some((Lineweight::ByLayer, false)),
"byblock" => return Some((Lineweight::ByBlock, false)),
"default" => return Some((Lineweight::Default, false)),
_ => {}
}
let mm = number(t).filter(|v| v.is_finite() && *v >= 0.0)?;
let want = mm * 100.0;
let near = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs()))?;
Some((Lineweight::Mm100(near), (f64::from(near) - want).abs() > 1e-9))
}
impl Interactive for ChpropM {
fn name(&self) -> &'static str {
"CHPROP"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.select = SelectPhase::begin(s);
if self.select.done {
let locked = locked(s, &self.select.picked);
if !locked.is_empty() {
self.select.picked.retain(|h| !locked.contains(h));
say_locked(s, locked.len());
s.set_selection(self.select.picked.clone());
// Nothing left of the pickfirst selection: ask for objects instead.
self.select.done = !self.select.picked.is_empty();
}
}
if self.select.done {
self.handles = self.select.picked.clone();
self.ask = Some(Ask::Property);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
let Some(ask) = self.ask else { return self.select.prompt() };
match ask {
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES),
Ask::Color => {
Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase()))
}
Ask::TrueColor => Prompt::new("Red,Green,Blue", Accept::TEXT),
Ask::Layer => Prompt::new("Enter new layer name", Accept::TEXT).default(self.current(s, |e| e.common.layer.clone())),
Ask::Linetype => Prompt::new("Enter new linetype name", Accept::TEXT).default(self.current(s, |e| e.common.linetype.clone())),
Ask::LtScale => {
Prompt::new("Specify new linetype scale", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.ltscale)))
}
Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))),
Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))),
Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(ask) = self.ask else {
// Objects on locked layers are left out, and said after what was found.
let (i, n) = match i {
Input::Pick(hs) if !self.select.removing => {
let locked = locked(s, &hs);
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
}
i => (i, 0),
};
let outcome = self.select.feed(s, &i)?;
say_locked(s, n);
return Ok(match outcome {
SelOutcome::More => Step::Continue,
SelOutcome::Done(hs) => {
self.handles = hs;
self.ask = Some(Ask::Property);
Step::Continue
}
SelOutcome::Empty => {
s.set_selection(Vec::new());
Step::Done
}
});
};
match (ask, i) {
(Ask::Property, Input::Enter) => {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(Ask::Property, Input::Keyword(k)) => {
self.ask = Some(match k.as_str() {
"Color" => Ask::Color,
"LAyer" => Ask::Layer,
"LType" => Ask::Linetype,
"ltScale" => Ask::LtScale,
"LWeight" => Ask::Lineweight,
"Thickness" => Ask::Thickness,
_ => Ask::Transparency,
});
}
(Ask::Property, Input::Text(t)) => {
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." });
}
(Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor),
// Enter keeps the value.
(_, Input::Enter) => self.ask = Some(Ask::Property),
(ask, Input::Text(t) | Input::Keyword(t)) => {
// A refused answer asks again.
let taken = self.answer(s, ask, &t)?;
self.ask = Some(if taken { Ask::Property } else { ask });
}
_ => {}
}
Ok(Step::Continue)
}
}
+1 -1
View File
@@ -474,7 +474,7 @@ impl Interactive for TrimM {
Input::Point(p) => {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
let Some(h) = s.pick_object(&space, p, ap)? else {
// A pick in empty space opens a crossing window there.
self.stage = Stage::Crossing(Some(p));
return Ok(Step::Continue);
+1 -1
View File
@@ -239,7 +239,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
v.width = w;
v.height = h;
}
EntityKind::Wipeout(w) => *w.boundary.get_mut(index)? = to,
EntityKind::Wipeout(w) => w.set_vertex(index, to)?,
EntityKind::Unknown(_) => return None,
}
Some(k)
+15
View File
@@ -169,6 +169,21 @@ fn other_entity_grips() {
assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0)));
}
#[test]
fn wipeout_grips_reject_the_closing_marker_and_hostile_input() {
let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary: vec![v(0.0, 0.0), v(7.0, 1.0), v(3.0, 7.0), v(0.0, 0.0)] });
for (index, to) in [(3, v(2.0, 2.0)), (usize::MAX, v(2.0, 2.0)), (0, v(f64::NAN, 0.0)), (0, v(0.0, f64::INFINITY))] {
assert!(stretch_grip(&kind, index, to).is_none());
}
for boundary in [vec![], vec![v(0.0, 0.0)], vec![v(0.0, 0.0), v(0.0, 0.0)]] {
let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary });
for index in [0, 1, usize::MAX] {
let result = stretch_grip(&kind, index, v(2.0, 2.0));
assert_eq!(result.is_some(), index < kind.grips().len());
}
}
}
#[test]
fn grip_modes() {
let mut s = Session::new();
+42 -1
View File
@@ -105,10 +105,51 @@ impl Interactive for Guarded {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::Running;
thread_local! {
static PANIC_AFTER_COMMAND: Cell<bool> = const { Cell::new(false) };
}
/// Called at the start of the passes after a command, which nothing can make panic on demand.
pub(crate) fn panic_if_armed() {
assert!(!PANIC_AFTER_COMMAND.get(), "a pass after the command panicked");
}
/// A panic in the passes after a JSON command: an error, and the drawing as it was.
#[test]
fn a_panic_after_a_command_restores_the_drawing() {
let mut s = Session::new();
s.execute("line", &serde_json::json!({"points": [[0, 0], [1, 0]]})).unwrap();
let undo = s.state().unwrap().undo.len();
PANIC_AFTER_COMMAND.set(true);
let r = s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1}));
PANIC_AFTER_COMMAND.set(false);
assert!(matches!(&r, Err(EngineError::Internal(id, m)) if id == "circle" && m.contains("after the command")), "{r:?}");
assert_eq!(points(&s), 1, "the circle is gone with the failed command");
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for it");
s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
assert_eq!(points(&s), 2, "the session still works");
}
/// The same at the end of an interactive command, where nothing returns the error to a caller.
#[test]
fn a_panic_after_an_interactive_command_restores_the_drawing() {
let mut s = Session::new();
s.cmdline("line 0,0 1,0").unwrap();
PANIC_AFTER_COMMAND.set(true);
let r = s.input(Input::Enter);
PANIC_AFTER_COMMAND.set(false);
assert!(r.is_ok(), "{r:?}");
assert!(s.running.is_none());
assert_eq!(points(&s), 0, "the drawing is restored");
assert!(s.log.iter().any(|l| l.contains("Internal error in line")), "{:?}", s.log);
s.cmdline("line 0,0 1,0").unwrap();
assert!(s.running.is_some(), "the session still works");
}
fn dot(p: Vec2) -> EntityKind {
EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 })
}
+9
View File
@@ -142,6 +142,10 @@ pub struct Session {
lisp_last: Option<std::result::Result<lisp::value::Value, String>>,
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
/// The object named with a point for the input a prompt picking one object is taking
/// ([`Session::input_object`]): a pick at exactly that point picks that object
/// ([`Session::pick_object`]).
pick_hint: Option<(Handle, Vec2)>,
}
impl Default for Session {
@@ -192,6 +196,7 @@ impl Session {
lisp: lisp::LispState::default(),
lisp_last: None,
hatch_picks: None,
pick_hint: None,
}
}
@@ -324,3 +329,7 @@ impl Session {
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "../tests/support/temp_dir.rs"]
mod temp_dir;
+32 -6
View File
@@ -64,8 +64,10 @@ fn items(p: &PickSet) -> Vec<Handle> {
/// `(command arg…)`: arguments go to the command line as typed input: a string starts a command
/// or answers a prompt ("" is Enter), a number or a point is entered, an entity name or a
/// selection set picks objects, and `pause` waits for the user. A command not finished by the
/// arguments stays active for the next `command` call. No arguments: cancel the active command.
/// selection set picks objects (a name, alone or with a point as `entsel` gives it, also answers
/// a prompt picking one object: [`Session::input_object`]), and `pause` waits for the user. A
/// command not finished by the arguments stays active for the next `command` call. No
/// arguments: cancel the active command.
pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
if a.is_empty() {
if ctx.s.running.is_some() {
@@ -108,14 +110,14 @@ pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
}
}
}
Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])),
Value::Ename(h) => named(ctx.s, *h, None),
Value::PickSet(p) => feed(ctx.s, Input::Pick(items(p))),
Value::Cons(_) => {
if let Some(p) = arg.as_point() {
feed(ctx.s, Input::Point(Vec2::new(p[0], p[1])));
} else if let Value::Ename(h) = arg.car() {
// An `entsel` result: (ename point).
feed(ctx.s, Input::Pick(vec![h]));
named(ctx.s, h, arg.cdr().car().as_point().map(|p| Vec2::new(p[0], p[1])));
} else {
return Err(LispError::bad_arg("command argument", arg));
}
@@ -134,6 +136,14 @@ fn feed(s: &mut Session, input: Input) {
}
}
fn named(s: &mut Session, h: Handle, at: Option<Vec2>) {
if s.running.is_some()
&& let Err(e) = s.input_object(h, at)
{
s.echo(e.to_string());
}
}
// ---------------- system variables ----------------
/// A JSON value as LISP: integers, reals, strings, lists (points).
@@ -181,10 +191,22 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
})
}
/// Angle variables CADCraft reports and takes in degrees, as the SETVAR command shows them, which
/// AutoLISP's `getvar` and `setvar` give and take in radians, as AutoCAD 2024's do: each of these
/// set to 30 by the SETVAR command, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987.
const RADIAN_VARS: [&str; 6] = ["ANGBASE", "CHAMFERD", "DIMJOGANG", "HPANG", "POLARANG", "SNAPANG"];
fn in_radians(name: &str) -> bool {
RADIAN_VARS.iter().any(|v| v.eq_ignore_ascii_case(name.trim()))
}
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let name = string(&a[0])?;
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
Ok(Flow::Value(match crate::sysvars::get(ctx.s, &name) {
Some(serde_json::Value::Number(n)) if in_radians(&name) => Value::Real(n.as_f64().unwrap_or(0.0).to_radians()),
v => v.map(|v| from_json(&v)).unwrap_or_default(),
}))
}
/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors.
@@ -195,6 +217,10 @@ pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
};
let json = match json {
serde_json::Value::Number(n) if in_radians(&name) => serde_json::json!(n.as_f64().unwrap_or(0.0).to_degrees()),
other => other,
};
match crate::sysvars::set(ctx.s, &name, &json) {
Ok(()) => Ok(Flow::Value(a[1].clone())),
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
@@ -1029,7 +1055,7 @@ pub fn rtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
}
fn decimal(v: f64, prec: usize, dimzin: i64) -> String {
let mut s = format!("{v:.prec$}");
let mut s = crate::units::fixed(v, prec);
if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() {
s = s.trim_start_matches('-').to_string();
}
+165 -2
View File
@@ -405,7 +405,7 @@ fn entity_lists_hold_angles_in_radians() {
// MTEXT: 50 before the column type (75) is the rotation, after it a column height.
ok(
&mut s,
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))",
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 2.0) '(50 . 30.0) '(50 . 20.0)))",
);
let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))),
@@ -528,7 +528,8 @@ fn interactive_routine_waits_for_the_user() {
#[test]
fn getangle_is_relative_to_angbase() {
let mut s = session();
s.cmdline("(setvar \"ANGBASE\" 90)").unwrap();
// AutoLISP sets angle variables in radians: ANGBASE 90 degrees.
s.cmdline("(setvar \"ANGBASE\" (/ pi 2))").unwrap();
// A picked point at 180° in world terms is 90° from ANGBASE.
s.cmdline("(setq a (getangle '(0 0)))").unwrap();
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
@@ -543,6 +544,38 @@ fn getangle_is_relative_to_angbase() {
assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159");
}
/// `getvar` and `setvar` take angle variables in radians, as AutoCAD 2024's do (each set to 30 by
/// SETVAR, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987; DIMJOGANG's 45 degrees reads
/// 0.785398), while the SETVAR command keeps degrees.
#[test]
fn getvar_and_setvar_take_angle_variables_in_radians() {
let mut s = session();
let degrees = |s: &Session, name: &str| crate::sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap();
// The defaults: polar tracking every 90 degrees, a 45-degree jog.
assert_eq!(ok(&mut s, "(list (equal (getvar \"POLARANG\") (/ pi 2) 1e-12) (equal (getvar \"DIMJOGANG\") (/ pi 4) 1e-12))"), "(T T)");
// Set from LISP in radians, shown by SETVAR in degrees.
for (name, radians, deg) in
[("SNAPANG", "(/ pi 6)", 30.0), ("CHAMFERD", "0.95993108859688", 55.0), ("ANGBASE", "(/ pi 2)", 90.0), ("POLARANG", "(/ pi 4)", 45.0)]
{
ok(&mut s, &format!("(setvar \"{name}\" {radians})"));
assert!((degrees(&s, name) - deg).abs() < 1e-9, "{name}: {}", degrees(&s, name));
assert_eq!(ok(&mut s, &format!("(equal (getvar \"{name}\") {radians} 1e-12)")), "T", "{name}");
}
// Set by the SETVAR command in degrees, read by LISP in radians.
s.cmdline("SETVAR HPANG 45").unwrap();
assert!((degrees(&s, "HPANG") - 45.0).abs() < 1e-9);
assert_eq!(ok(&mut s, "(equal (getvar \"HPANG\") 0.7853981633974482 1e-12)"), "T");
// DIMJOGANG set to a whole number of degrees still takes a fraction of one from LISP.
s.cmdline("SETVAR DIMJOGANG 45").unwrap();
ok(&mut s, "(setvar \"DIMJOGANG\" 0.5)");
assert!((degrees(&s, "DIMJOGANG") - 0.5_f64.to_degrees()).abs() < 1e-9, "{}", degrees(&s, "DIMJOGANG"));
// It takes the value the dimension style takes: `true` is a DIMSCALE of 1.
crate::sysvars::set(&mut s, "DIMSCALE", &serde_json::json!(true)).unwrap();
assert_eq!(crate::sysvars::get(&s, "DIMSCALE").and_then(|v| v.as_f64()), Some(1.0));
// Other variables are as they were.
assert_eq!(ok(&mut s, "(getvar \"ANGDIR\")"), "0");
}
#[test]
fn appload_and_load() {
let mut s = session();
@@ -563,6 +596,111 @@ fn appload_and_load() {
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
}
/// `command` gives an entity name, alone or as `entsel` returns it (name and point), to a prompt
/// picking one object: the point picks that object, and a bare name stands at its DXF group 10
/// point. Each call and its numbers are AutoCAD 2024's (accoreconsole, read back with `entget`);
/// AutoCAD ran TRIM and EXTEND in Standard mode there.
#[test]
fn command_names_objects_at_prompts_picking_one() {
use cadcraft_doc::EntityKind;
let near = |a: [f64; 2], b: [f64; 2]| (0..2).all(|i| (a[i] - b[i]).abs() <= 1e-9 * a[i].abs().max(1.0));
// The drawing's points, lines and polyline widths after `forms`, each evaluated on its own.
let run = |forms: &[&str]| {
let mut s = session();
ok(&mut s, "(setvar \"TRIMEXTENDMODE\" 0)");
for f in forms {
ok(&mut s, f);
}
assert!(s.running.is_none(), "{forms:?}: a command is still running");
let (mut points, mut lines, mut widths) = (Vec::new(), Vec::new(), Vec::new());
for e in s.doc().unwrap().model.iter() {
match &e.kind {
EntityKind::Point(p) => points.push([p.p.x, p.p.y]),
EntityKind::Line(l) => lines.push(([l.a.x, l.a.y], [l.b.x, l.b.y])),
EntityKind::LwPolyline(pl) => widths.push(pl.const_width),
_ => {}
}
}
(points, lines, widths)
};
let has_point = |points: &[[f64; 2]], p: [f64; 2]| points.iter().any(|q| near(*q, p));
let has_line = |lines: &[([f64; 2], [f64; 2])], a: [f64; 2], b: [f64; 2]| lines.iter().any(|(p, q)| near(*p, a) && near(*q, b));
let line = |name: &str, a: [f64; 2], b: [f64; 2]| {
format!("(setq {name} (progn (entmake '((0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0))) (entlast)))", a[0], a[1], b[0], b[1])
};
// A circle named alone stands at its centre, where nothing is drawn.
let (points, ..) = run(&[
"(setq a (progn (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 15.0))) (entlast)))",
"(command \"_.MEASURE\" a 10.275598705220492)",
]);
assert!(points.len() == 9 && has_point(&points, [14.89599800946832, -1.763304653744166]), "{points:?}");
// An arc named alone stands at its centre too, as near to either end: MEASURE runs from its
// end (250°), so its points run from 233.4° down to 34.4°, not from 46.6° up.
let (points, ..) = run(&[
"(setq a (progn (entmake '((0 . \"ARC\") (10 -35000.0 81000.0 0.0) (40 . 1000.0) (50 . 0.5235987755982988) (51 . 4.363323129985824))) (entlast)))",
"(command \"_.MEASURE\" a 289.4960530820059)",
]);
assert!(points.len() == 13 && has_point(&points, [-35596.04133310627, 80197.0462471419]), "{points:?}");
assert!(has_point(&points, [-34174.59349504207, 81564.53883973838]), "{points:?}");
// PEDIT takes a polyline by name.
let (_, _, widths) = run(&[
"(setq a (progn (entmake '((0 . \"LWPOLYLINE\") (100 . \"AcDbEntity\") (100 . \"AcDbPolyline\") (90 . 3) (70 . 0) (10 0.0 0.0) (10 30.0 0.0) (42 . 0.4) (10 40.0 20.0))) (entlast)))",
"(command \"_.PEDIT\" a \"_W\" 1.25 \"\")",
]);
assert_eq!(widths, [1.25]);
// `entsel` lists: the point picks the end LENGTHEN moves, BREAK's first point, the piece
// TRIM removes and the end EXTEND moves.
let (_, lines, _) = run(&[
&line("a", [0.0, 0.0], [-11.745250375031729, -27.60523670660726]),
"(command \"_.LENGTHEN\" \"_DE\" 5.0 (list a '(-10.962233683362948 -25.764887592833446)) \"\")",
]);
assert!(has_line(&lines, [0.0, 0.0], [-13.70279210420368, -32.2061094910418]), "{lines:?}");
let (_, lines, _) = run(&[
&line("a", [0.0, 0.0], [-97.11873949964188, 23.831710765295487]),
"(command \"_.BREAK\" (list a '(-19.77309329359773 4.852067095763353)) '(-77.20177993977879 18.94434070674469))",
]);
assert!(lines.len() == 2 && has_line(&lines, [0.0, 0.0], [-19.77309329359773, 4.852067095763352]), "{lines:?}");
assert!(has_line(&lines, [-77.20177993977879, 18.94434070674469], [-97.11873949964188, 23.83171076529548]), "{lines:?}");
let (_, lines, _) = run(&[
&line("a", [-38.39479850296135, -32.02872847799295], [38.39479850296135, 32.02872847799295]),
&line("b", [-9.422796057479543, -30.193534825244917], [-15.589739201426854, 9.328216303207391]),
&line("c", [35.428336962443794, 7.710053620262788], [-2.7893410422311042, 19.517216189270982]),
"(command \"_.TRIM\" a b c \"\" (list a '(1.9066151653265737 1.5904878218740615)) \"\")",
]);
assert!(lines.len() == 4 && has_line(&lines, [-38.39479850296135, -32.02872847799294], [-12.50626762945319, -10.43265926101876]), "{lines:?}");
assert!(has_line(&lines, [16.31949796010634, 13.61363490476688], [38.39479850296135, 32.02872847799294]), "{lines:?}");
let (_, lines, _) = run(&[
&line("a", [0.0, 0.0], [18.819750237318146, -6.7688256739971955]),
&line("b", [17.056820153998288, -35.36317014637398], [58.2221807952743, 8.2878674503852]),
"(command \"_.EXTEND\" a b \"\" (list a '(14.11481267798861 -5.076619255497897)) \"\")",
]);
assert!(has_line(&lines, [0.0, 0.0], [37.63950047463629, -13.53765134799439]), "{lines:?}");
}
#[test]
fn a_named_object_holds_for_its_own_input_in_its_own_space() {
use cadcraft_doc::Space;
let mut s = session();
ok(&mut s, "(setq a (progn (entmake '((0 . \"LINE\") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0))) (entlast)))");
// BREAK takes A at (5,3), off the line; a later pick there finds nothing.
ok(&mut s, "(command \"_.BREAK\" (list a '(5.0 3.0)))");
assert!(s.prompt_text().contains("Specify second break point"), "{}", s.prompt_text());
let model = s.space();
assert_eq!(s.pick_object(&model, cadcraft_geom::Vec2::new(5.0, 3.0), 0.5).unwrap(), None);
s.cancel();
// A paper-space copy of A named in model space picks nothing, not A under its start.
let p = {
let d = s.doc_mut().unwrap();
let e = d.model.iter().next().unwrap();
let (common, kind) = (e.common.clone(), e.kind.clone());
d.add(&Space::Paper("Layout1".into()), common, kind).unwrap()
};
ok(&mut s, &format!("(command \"_.BREAK\" (handent \"{}\"))", p.hex()));
assert!(s.prompt_text().contains("Select object"), "{}", s.prompt_text());
s.cancel();
}
#[test]
fn appload_reads_a_lsp_path_and_reports_its_commands() {
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
@@ -876,3 +1014,28 @@ fn angtof_reads_the_string_in_the_given_units() {
assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");
assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil");
}
/// #1920: the value of an expression typed at a prompt is the answer, not another expression.
/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack
/// overflowed and the process aborted (no `catch_unwind` catches that).
#[test]
fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {
let mut s = session();
s.cmdline("circle (setq q \"!q\")").unwrap();
assert_eq!(ok(&mut s, "q"), "\"!q\"");
// "!q" is no centre point, so CIRCLE still asks for one; the session carries on.
assert!(s.running.is_some());
s.cancel();
// A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3.
s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap();
assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius");
s.cancel();
// Values that answer the prompt still do.
s.cmdline("(setq r \"4\")").unwrap();
s.cmdline("circle 0,0 !r").unwrap();
let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
cadcraft_doc::EntityKind::Circle(c) => Some(c.radius),
_ => None,
});
assert_eq!(r, Some(4.0));
}
+3 -5
View File
@@ -136,11 +136,9 @@ impl Formatted {
i += 2;
match code {
None => {}
Some('P') => {
if f.paras.len() < MAX_PARAS {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
Some('P') if f.paras.len() < MAX_PARAS => {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
Some('L') => cur.underline = true,
+32 -2
View File
@@ -180,10 +180,13 @@ impl Prompt {
(k, k.to_ascii_lowercase(), shortcut)
});
// A whole keyword or its capital-letter shortcut wins over a prefix of another keyword
// (`M` is Multiple, not a prefix of mEthod).
// (`M` is Multiple, not a prefix of mEthod). A prefix reaches past the keyword's last
// capital, as in AutoCAD: `L` is none of LAyer, LType, ltScale or LWeight (AutoCAD 2024's
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
keys.clone()
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
.or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl)))
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
.map(|(k, _, _)| k.clone())
}
}
@@ -341,5 +344,32 @@ mod tests {
let q = Prompt::new("", Accept::POINT).kw(&["3P", "2P", "Ttr"]);
assert_eq!(q.match_keyword("3p").as_deref(), Some("3P"));
assert_eq!(q.match_keyword("t").as_deref(), Some("Ttr"));
// A prefix reaches past the last capital (AutoCAD 2024's CHPROP: `L` is ambiguous).
let c =
Prompt::new("Enter property to change", Accept::TEXT).kw(&["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]);
assert_eq!(c.match_keyword("l"), None);
assert_eq!(c.match_keyword("la").as_deref(), Some("LAyer"));
assert_eq!(c.match_keyword("lts").as_deref(), Some("ltScale"));
assert_eq!(c.match_keyword("s").as_deref(), Some("ltScale"));
assert_eq!(c.match_keyword("tr").as_deref(), Some("TRansparency"));
// Even when no other keyword starts with it: AutoCAD 2024 answers QDIM's `DA` with "Point or
// option keyword required." and PEDIT's vertex `E` with "Invalid option keyword.".
let q = Prompt::new("", Accept::POINT).kw(&[
"Continuous",
"Staggered",
"Baseline",
"Ordinate",
"Radius",
"Diameter",
"datumPoint",
"Edit",
"seTtings",
]);
assert_eq!(q.match_keyword("da"), None);
assert_eq!(q.match_keyword("datump").as_deref(), Some("datumPoint"));
assert_eq!(q.match_keyword("p").as_deref(), Some("datumPoint"));
let v = Prompt::new("", Accept::TEXT).kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]);
assert_eq!(v.match_keyword("e"), None);
assert_eq!(v.match_keyword("ex").as_deref(), Some("eXit"));
}
}
+33 -17
View File
@@ -181,7 +181,9 @@ pub(crate) fn candidates<'a>(
}
}
/// The topmost entity within `aperture` of `p`.
/// The topmost entity within `aperture` of `p`: the one drawn last (on top in the draw order), not
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
/// picked, and DRAWORDER changes which).
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
let ix = spatial::index(d, space);
pick_with(d, space, &ix, p, aperture)
@@ -189,7 +191,7 @@ pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
let probe = Bounds2::new(p, p).expand(aperture);
let mut best: Option<(f64, Handle)> = None;
// Candidates come topmost first: the first within the aperture is the one.
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
if !selectable(d, e) {
continue;
@@ -198,30 +200,24 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) {
continue;
}
let dist = entity_distance(d, e, p, aperture / 4.0);
if dist <= aperture && best.is_none_or(|(bd, _)| dist < bd - 1e-12) {
best = Some((dist, e.handle));
if dist == 0.0 {
break;
}
if entity_distance(d, e, p, aperture / 4.0) <= aperture {
return Some(e.handle);
}
}
best.map(|(_, h)| h)
None
}
/// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is
/// what [`pick`] chooses): the objects selection cycling lists.
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
/// the objects selection cycling lists.
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
let ix = spatial::index(d, space);
let probe = Bounds2::new(p, p).expand(aperture);
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
let mut hits: Vec<(f64, Handle)> = cands
cands
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
.map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle))
.filter(|(dist, _)| *dist <= aperture)
.collect();
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
hits.into_iter().map(|(_, h)| h).collect()
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
.map(|(e, _)| e.handle)
.collect()
}
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
@@ -486,6 +482,26 @@ mod tests {
assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(0.0, 1.0), (0.0, 5.0)]), 0);
assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0);
}
/// Two objects under the pickbox: AutoCAD 2024 picks the one drawn last, not the nearer one,
/// and DRAWORDER changes which (`(ssget pt)` in accoreconsole).
#[test]
fn a_pick_takes_the_object_drawn_last() {
let mut d = Drawing::new_metric();
let circle = d
.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(50.0, 50.0, 0.0), radius: 10.0 }))
.unwrap();
let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(40.5, 45.0, 0.0), b: Vec3::new(40.5, 55.0, 0.0) });
let line = d.add(&Space::Model, Common::default(), line).unwrap();
// 0.05 from the circle and 0.45 from the line, both inside an aperture of 1.
let p = Vec2::new(40.05, 50.0);
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(line));
assert_eq!(pick_all(&d, &Space::Model, p, 1.0), vec![line, circle]);
d.model.send_to_back(line);
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(circle));
// Only one within reach: that one, whatever the order.
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
}
}
#[cfg(test)]
+11 -4
View File
@@ -69,6 +69,13 @@ impl Session {
/// One token typed while a command runs.
pub(crate) fn typed(&mut self, t: &str) -> Result<()> {
self.typed_token(t, true)
}
/// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value
/// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would
/// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920).
fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> {
let Some(prompt) = self.current_prompt() else { return Ok(()) };
let tt = t.trim();
if tt.is_empty() {
@@ -84,7 +91,7 @@ impl Session {
}
// A LISP expression typed at a command's prompt: its value is the answer.
// Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").
if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
if lisp && lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
let src = tt.strip_prefix('!').unwrap_or(tt).to_string();
let v = match self.lisp_eval(&src, false, true, Vec::new()) {
Ok(Some(Ok(v))) => v,
@@ -96,9 +103,9 @@ impl Session {
};
return match v {
lisp::value::Value::Nil => Ok(()),
lisp::value::Value::Int(i) => self.typed(&i.to_string()),
lisp::value::Value::Real(r) => self.typed(&format!("{r}")),
lisp::value::Value::Str(s) => self.typed(&s),
lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false),
lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false),
lisp::value::Value::Str(s) => self.typed_token(&s, false),
other => match other.as_point() {
Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
None => {
+20
View File
@@ -4,7 +4,27 @@ impl Session {
// ---------------- programmatic execution ----------------
/// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes.
///
/// The command itself is guarded below, where its own panic can be told apart. This guard is
/// for everything around it: the passes after a command (constraints, associativity, groups)
/// are as able to panic as the command, and a panic there restores the drawing the same way.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute_guarded(id, params))) {
Ok(r) => r,
Err(p) => {
if let Some((doc, sel, uid)) = before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
{
st.doc = doc;
st.selection = sel;
}
Err(EngineError::Internal(id.into(), crate::guard::message(&*p)))
}
}
}
fn execute_guarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
+17
View File
@@ -9,6 +9,7 @@ impl Session {
/// Start a command by name or alias as if typed at the command line.
pub fn start(&mut self, name: &str) -> Result<()> {
self.last_polar = None;
self.pick_hint = None;
let name = name.trim();
let (transparent, name) = match name.strip_prefix('\'') {
Some(n) => (true, n),
@@ -340,7 +341,21 @@ impl Session {
}
}
/// End a command: every way out of one comes through here. The passes after a command
/// (constraints, associativity, groups) can panic like the command itself; a panic restores
/// the drawing as it was before the command, as a panic in its `input` does.
fn finish(&mut self, run: Running, cancelled: bool) {
let (id, before, selection) = (run.id.clone(), run.before.clone(), run.selection_before.clone());
if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.finish_guarded(run, cancelled))) {
if let Ok(st) = self.state_mut() {
st.doc = before;
st.selection = selection;
}
self.echo(format!("Internal error in {id}: {}", crate::guard::message(&*p)));
}
}
fn finish_guarded(&mut self, run: Running, cancelled: bool) {
// A typed selection mode or SIngle never outlives its command.
self.select_mode = None;
self.select_single = false;
@@ -390,6 +405,8 @@ impl Session {
/// started in, so associativity is not applied across a document switch; `ok` is false for a
/// failed or non-undoable programmatic command, which skips the constraint pass.
pub(super) fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
#[cfg(test)]
crate::guard::tests::panic_if_armed();
cmd::groups::after_command(self, before);
cmd::constraints::after_command(self, before, ok);
assoc::after_command(self, before, uid);
+44 -1
View File
@@ -1,4 +1,4 @@
use super::{Bounds2, Handle, Input, PendingWindow, Result, Session, Vec2, cmd, select};
use super::{Bounds2, Drawing, Entity, Handle, Input, PendingWindow, Result, Session, Space, Vec2, cmd, select};
impl Session {
// ---------------- picking without a command ----------------
@@ -125,4 +125,47 @@ impl Session {
st.previous_selection = sel.to_vec();
}
}
// ---------------- one object by name ----------------
/// Give the running command an object by name, as AutoLISP's `command` does with an entity
/// name or an `entsel` list (name and point). A prompt picking one object with a point
/// (BREAK's "Select object", MEASURE's "Select object to measure"…) takes the point, and a
/// pick there finds that object ([`Session::pick_object`]) wherever it lies. A bare name
/// stands at the object's DXF group 10 point (a line's start, a circle's centre, a
/// polyline's first vertex), the point AutoCAD picks it at. The name holds for that input
/// only: a later pick at the same point finds what lies there. Other prompts take the object
/// as a pick.
pub fn input_object(&mut self, h: Handle, at: Option<Vec2>) -> Result<()> {
let one = self.current_prompt().is_some_and(|p| p.accept.point && !p.accept.select && p.picks_objects());
let p = if one { at.or_else(|| self.doc().ok().and_then(|d| group_10(d, d.entity(h)?))) } else { None };
let Some(p) = p.filter(|p| p.is_finite()) else { return self.input(Input::Pick(vec![h])) };
self.pick_hint = Some((h, p));
let r = self.input(Input::Point(p));
self.pick_hint = None;
r
}
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
/// object under `p` within `aperture`.
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
let d = self.doc()?;
if let Some((h, q)) = self.pick_hint
&& q == p
{
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
}
Ok(select::pick(d, space, p, aperture))
}
}
/// An object's DXF group 10 point: the first point of its entity list, or its first grip where
/// entity lists are not built in.
fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() };
let (_, tags) = (codec.groups)(d, e);
let i = tags.iter().position(|t| t.code == 10)?;
let y = tags.get(i + 1).filter(|t| t.code == 20)?;
Some(Vec2::new(tags[i].value.as_f64(), y.value.as_f64()))
}
+19
View File
@@ -70,6 +70,7 @@ const SESSION_VARS: &[&str] = &[
"FONTFALLBACK",
"SAVETIME",
"SAVEFILEPATH",
"ISAVEBAK",
];
pub fn get(s: &Session, name: &str) -> Option<Value> {
@@ -154,6 +155,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
// Autosave (profile settings too).
"SAVETIME" => json!(crate::autosave::savetime()),
"SAVEFILEPATH" => json!(crate::autosave::savefilepath()),
"ISAVEBAK" => json!(i32::from(crate::autosave::isavebak())),
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
@@ -366,6 +368,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32,
),
"SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?),
"ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?),
// A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line).
"ANGBASE" => {
let a = match v {
@@ -375,6 +378,15 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?;
s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a));
}
// Whole numbers inside the range UNITS clamps to.
"LUNITS" | "LUPREC" | "AUNITS" | "AUPREC" => {
let (_, lo, hi) = crate::cmd::UNIT_RANGES.iter().find(|(k, ..)| k.eq_ignore_ascii_case(&n)).copied().ok_or_else(bad)?;
let i = as_i(v)
.or_else(|| as_f64(v).filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64))
.filter(|i| (lo..=hi).contains(i))
.ok_or_else(bad)?;
s.doc_mut()?.header.set_i64(&n, i);
}
"CONSTRAINTINFER" => s.doc_mut()?.parametric.settings.infer = as_bool(v).ok_or_else(bad)?,
_ => {
if let Some(r) = table::set(s, &n, v) {
@@ -387,6 +399,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
return Err(bad());
}
// A real style field (DIMJOGANG, DIMSCALE...) is a real variable, even set to a
// whole number first, so a later fraction still fits. It takes the value the style
// took.
if let Some(f) = serde_json::to_value(&st).ok().and_then(|j| j.get(*field).filter(|f| f.is_f64()).and_then(|f| f.as_f64())) {
d.header.set_f64(&n, f);
return Ok(());
}
}
let val = match v {
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
+51 -19
View File
@@ -344,6 +344,15 @@ fn fillet_two_lines() {
assert!(g.iter().any(|p| p.near(Vec2::new(2.0, 0.0), 1e-9)));
}
#[test]
fn offset_zero_distance_is_rejected() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let r = s.execute("offset", &json!({"handle": "100", "distance": 0, "side": [1, 1]}));
assert!(r.is_err(), "offset distance 0 made a coincident copy: {r:?}");
assert_eq!(s.doc().unwrap().model.len(), 1);
}
#[test]
fn offset_circle_and_polyline() {
let mut s = Session::new();
@@ -633,6 +642,17 @@ fn setvar_keeps_header_types() {
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0);
}
#[test]
fn setvar_rejects_out_of_range_units() {
let mut s = Session::new();
for (name, v) in [("LUPREC", 100), ("LUNITS", 9), ("LUNITS", 0), ("AUPREC", -5), ("AUNITS", 5)] {
assert!(s.execute("setvar", &json!({"name": name, "value": v})).is_err(), "{name} {v}");
}
s.execute("setvar", &json!({"name": "LUPREC", "value": 8})).unwrap();
assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 8);
assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9);
}
#[test]
fn dynamic_input_pointer_settings() {
let mut s = Session::new();
@@ -680,25 +700,6 @@ fn cancel_keeps_partial_line_as_one_undo() {
assert_eq!(s.doc().unwrap().model.len(), 0);
}
#[test]
fn hostile_params_never_panic() {
let mut s = Session::new();
for c in command_specs() {
for p in [
json!(null),
json!([]),
json!("x"),
json!({"points": [[1e308, -1e308], ["a"]]}),
json!({"handles": ["zz", 99999]}),
json!({"radius": -1, "center": [0, 0]}),
json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}),
] {
let _ = s.execute(c.id, &p);
}
}
}
#[test]
fn every_interactive_command_starts_and_cancels() {
for c in command_specs().iter().filter(|c| c.interactive.is_some()) {
@@ -1433,3 +1434,34 @@ fn hostile_variable_like_input_never_panics() {
s.cmdline("pickadd").unwrap();
assert!(setvar_prompt_open(&s));
}
#[test]
fn chamfer_rejects_negative_distances() {
let mut s = Session::new();
let h1 = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let h2 = s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap();
let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>());
let r = s.execute("chamfer", &json!({"h1": h1["handles"][0], "h2": h2["handles"][0], "p1": [5, 0], "p2": [0, 5], "d1": -3, "d2": 2}));
assert!(r.is_err(), "negative chamfer distance accepted: {r:?}");
let after = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::<Vec<_>>());
assert_eq!(before, after);
}
#[test]
fn array_counts_reject_invalid_values() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
for bad in [json!(-3), json!(0), json!(2.5)] {
for key in ["count", "rows", "levels"] {
let mut p = json!({"center": [0, 0], "associative": false});
p[key] = bad.clone();
assert!(s.execute("arraypolar", &p).is_err(), "arraypolar {key}={bad}");
}
for key in ["rows", "cols", "levels"] {
let mut p = json!({"associative": false});
p[key] = bad.clone();
assert!(s.execute("arrayrect", &p).is_err(), "arrayrect {key}={bad}");
}
}
s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap();
}
+80 -14
View File
@@ -68,23 +68,62 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
};
format!("{}{}{}", if neg { "-" } else { "" }, whole, fs)
}
_ => format!("{:.*}", prec, v),
_ => fixed(v, prec),
}
}
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`).
fn scientific(v: f64, prec: usize) -> String {
let s = format!("{:.*E}", prec, v);
match s.split_once('E') {
Some((mantissa, exp)) => {
let (sign, digits) = match exp.strip_prefix('-') {
Some(d) => ('-', d),
None => ('+', exp),
};
format!("{mantissa}E{sign}{digits:0>2}")
}
None => s,
/// `v` with `prec` decimals, rounded as AutoCAD rounds a number it shows (RTOS, LUNITS): at 15
/// significant digits, with a half rounded away from zero. AutoCAD 2024 gives 1.01 for 1.005 to two
/// places (its binary value is just below 1.005), 3 for 2.5 and -0.13 for -0.125, where Rust's
/// formatting rounds the binary value and a half to even (1.00, 2 and -0.12).
pub fn fixed(v: f64, prec: usize) -> String {
let plain = || format!("{v:.prec$}");
if !v.is_finite() {
return plain();
}
let sci = format!("{:.14e}", v.abs());
let Some((mantissa, exp)) = sci.split_once('e') else { return plain() };
let Ok(exp) = exp.parse::<i64>() else { return plain() };
let sig: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
// Digit `k` of `sig` is worth 10^(exp - k); keep those down to 10^-prec, in units of 10^-prec.
let digit = |k: i64| usize::try_from(k).ok().and_then(|k| sig.get(k)).copied().unwrap_or(0);
let keep = exp + 1 + prec as i64;
let mut units: Vec<u8> = (0..keep).map(digit).collect();
if keep >= 0 && digit(keep) >= 5 {
let mut carry = true;
for d in units.iter_mut().rev() {
if *d == 9 {
*d = 0;
} else {
*d += 1;
carry = false;
break;
}
}
if carry {
units.insert(0, 1);
}
}
let n: String = units.iter().map(|d| char::from(b'0' + d)).collect();
let n = format!("{:0>width$}", n.trim_start_matches('0'), width = prec + 1);
let (int, frac) = n.split_at(n.len() - prec);
let body = if prec == 0 { int.to_string() } else { format!("{int}.{frac}") };
if v < 0.0 && n.bytes().any(|b| b != b'0') { format!("-{body}") } else { body }
}
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`),
/// the mantissa rounded as [`fixed`] rounds.
fn scientific(v: f64, prec: usize) -> String {
let s = format!("{:.14e}", v);
let Some((m, e)) = s.split_once('e') else { return s };
let (Ok(m), Ok(mut e)) = (m.parse::<f64>(), e.parse::<i64>()) else { return s };
let mut mantissa = fixed(m, prec);
// 9.99… rounded up to 10: one more power of ten.
if mantissa.trim_start_matches('-').starts_with("10") {
e += 1;
mantissa = fixed(m / 10.0, prec);
}
format!("{mantissa}E{}{:0>2}", if e < 0 { '-' } else { '+' }, e.abs())
}
fn gcd(a: i64, b: i64) -> i64 {
@@ -435,11 +474,38 @@ mod tests {
assert_eq!(format_distance(11.6, 3, 0), "1'-0\"");
}
/// RTOS in AutoCAD 2024 (accoreconsole, DIMZIN 0): a half rounds away from zero, at 15
/// significant digits.
#[test]
fn numbers_round_as_autocad_rounds_them() {
for (v, prec, want) in [
(1500.5, 0, "1501"),
(2.5, 0, "3"),
(-2.5, 0, "-3"),
(0.125, 2, "0.13"),
(0.5, 0, "1"),
(1.005, 2, "1.01"),
(3.5, 0, "4"),
(0.0625, 3, "0.063"),
(-0.125, 2, "-0.13"),
(1234.5678, 3, "1234.568"),
] {
assert_eq!(format_distance(v, 2, prec), want, "{v} to {prec}");
}
assert_eq!(format_distance(2.5, 1, 0), "3E+00");
assert_eq!(format_distance(0.125, 1, 1), "1.3E-01");
assert_eq!(format_distance(9.96, 1, 1), "1.0E+01");
assert_eq!(fixed(-0.004, 2), "0.00");
assert_eq!(fixed(1e-20, 3), "0.000");
assert_eq!(fixed(123456789012.0, 1), "123456789012.0");
}
#[test]
fn scientific_exponent_is_signed_and_padded() {
assert_eq!(format_distance(17.5, 1, 4), "1.7500E+01");
assert_eq!(format_distance(0.00001, 1, 2), "1.00E-05");
assert_eq!(format_distance(1234.5, 1, 3), "1.234E+03");
// 1.2345 is halfway: away from zero, as AutoCAD rounds 2.5 to 3E+00.
assert_eq!(format_distance(1234.5, 1, 3), "1.235E+03");
assert_eq!(format_distance(12345678.0, 1, 3), "1.235E+07");
assert_eq!(format_distance(-17.5, 1, 4), "-1.7500E+01");
assert_eq!(format_distance(0.0, 1, 2), "0.00E+00");
+7 -4
View File
@@ -8,6 +8,11 @@ use cadcraft_engine::Session;
use cadcraft_engine::cmd::resolve_alias;
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
const PGP: &str = "\
@@ -55,10 +60,8 @@ fn an_alias_file_adds_and_overrides_aliases() {
#[test]
fn reinit_reads_the_alias_file_again() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("mine.pgp").to_string_lossy().into_owned();
std::fs::write(&file, "ZZR, *RECTANG\n").unwrap();
let dir = TempDir::new("aliases");
let file = dir.file("mine.pgp", "ZZR, *RECTANG\n");
let mut s = Session::new();
s.execute("aliasedit", &json!({ "file": file })).unwrap();
assert_eq!(resolve_alias("zzr"), "rectang");
+128 -1
View File
@@ -140,7 +140,8 @@ fn what_the_block_editor_refuses() {
assert!(s.execute("bclose", &json!({})).is_err(), "not open");
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open");
s.execute("bedit", &json!({ "name": "DOOR" })).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Door"), "the block already open stays open");
let r = s.execute("layout.set", &json!({ "name": "Layout1" }));
assert!(r.is_err_and(|e| e.to_string().contains("Block Editor")));
assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model);
@@ -370,3 +371,129 @@ fn the_current_drawing_opens_in_the_block_editor() {
s.undo().unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
}
/// BEDIT in the Block Editor switches to another block, as AutoCAD's does: changes not saved yet
/// are saved or discarded first (`save`), and a block it can't open leaves the editor as it was.
#[test]
fn bedit_in_the_block_editor_switches_blocks() {
let mut s = Session::new();
door(&mut s);
let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]);
s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap();
let model = s.doc().unwrap().model.len();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
line(&mut s, [0.0, 0.0], [0.0, 3.0]);
// Changes not saved: JSON must say what to do with them.
let e = s.execute("bedit", &json!({ "name": "Frame" })).unwrap_err().to_string();
assert!(e.contains("save"), "{e}");
assert_eq!(editor(&s).as_deref(), Some("Door"));
assert!(s.state().unwrap().block_unsaved(), "nothing was lost");
// A block that isn't there, or `save` that isn't a bool: refused, nothing changes.
assert!(s.execute("bedit", &json!({ "name": "Nope", "save": true })).is_err());
assert!(s.execute("bedit", &json!({ "name": "Frame", "save": "yes" })).is_err());
assert_eq!(editor(&s).as_deref(), Some("Door"));
assert!(s.state().unwrap().block_unsaved());
// Saved, then switched.
let r = s.execute("bedit", &json!({ "name": "frame", "save": true })).unwrap();
assert_eq!((r["block"].as_str(), r["saved"].as_bool()), (Some("Frame"), Some(true)));
assert_eq!(editor(&s).as_deref(), Some("Frame"));
assert_eq!(lines_of(&s, "Door").len(), 2, "Door keeps the new line");
// Discarded, then switched; with nothing to save `save` isn't needed.
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
s.execute("bedit", &json!({ "name": "Door", "save": false })).unwrap();
assert_eq!(lines_of(&s, "Frame").len(), 1, "Frame's change was discarded");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Frame"));
// A new block, and the drawing itself.
s.execute("bedit", &json!({ "name": "Gate", "create": true })).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Gate"));
s.execute("bedit", &json!({ "name": "<Current Drawing>" })).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert!(editor(&s).is_none());
assert_eq!(s.doc().unwrap().model.len(), model, "the drawing as it was");
}
/// A block BEDIT can't switch to is refused before it asks about the changes not saved, so the
/// question never traps the user; `check` asks the same of JSON callers.
#[test]
fn bedit_refuses_a_bad_target_before_asking_to_save() {
let mut s = Session::new();
door(&mut s);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
assert!(s.state().unwrap().block_unsaved());
s.script(
"BEDIT
Nope
",
)
.unwrap();
let message = s.current_prompt().map(|p| p.message).unwrap_or_default();
assert!(!message.contains("Save changes"), "no question for a block it can't open: {message}");
s.input(cadcraft_engine::Input::Cancel).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Door"));
assert!(s.state().unwrap().block_unsaved(), "nothing lost");
// The check, without switching or saving.
assert!(s.execute("bedit", &json!({ "name": "Nope", "check": true })).is_err());
assert!(s.execute("bedit", &json!({ "name": "Nope", "create": true, "check": true })).is_ok(), "a new name is fine");
assert_eq!(editor(&s).as_deref(), Some("Door"));
assert!(s.state().unwrap().block_unsaved());
}
/// Typed, BEDIT in the Block Editor asks whether to save the changes before it switches.
#[test]
fn bedit_typed_in_the_block_editor_asks_to_save_first() {
let mut s = Session::new();
door(&mut s);
let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]);
s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
// Nothing changed: it switches at once.
s.script(
"BEDIT
Frame
",
)
.unwrap();
assert!(s.running.is_none());
assert_eq!(editor(&s).as_deref(), Some("Frame"));
line(&mut s, [1.0, 1.0], [2.0, 2.0]);
s.script(
"BEDIT
Door
",
)
.unwrap();
assert_eq!(s.current_prompt().unwrap().message, "Save changes to block \"Frame\"?");
// Escape keeps the editor on Frame with its change.
s.input(cadcraft_engine::Input::Cancel).unwrap();
assert_eq!(editor(&s).as_deref(), Some("Frame"));
assert!(s.state().unwrap().block_unsaved());
s.script(
"BEDIT
Door
Y
",
)
.unwrap();
assert!(s.running.is_none());
assert_eq!(editor(&s).as_deref(), Some("Door"));
assert_eq!(lines_of(&s, "Frame").len(), 2, "saved before switching");
line(&mut s, [3.0, 3.0], [4.0, 4.0]);
s.script(
"BEDIT
Frame
N
",
)
.unwrap();
assert_eq!(editor(&s).as_deref(), Some("Frame"));
assert_eq!(lines_of(&s, "Door").len(), 1, "discarded");
}
+10 -12
View File
@@ -8,6 +8,11 @@ use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line};
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
/// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from
/// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10.
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
@@ -44,15 +49,6 @@ fn hooks() {
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
}
/// Write a test drawing file with $INSBASE `base`; returns its path.
fn file(name: &str, base: &str) -> String {
let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(name);
std::fs::write(&p, base).unwrap();
p.to_string_lossy().into_owned()
}
/// Lines in model space as (ax, ay, bx, by), rounded.
fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> {
let r = |v: f64| (v * 1e6).round() / 1e6;
@@ -105,7 +101,9 @@ fn star_name_inserts_the_blocks_objects_with_one_scale_factor() {
#[test]
fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
hooks();
let bolt = file("bolt.dxf", "10,10");
// Test drawing files hold their $INSBASE.
let dir = TempDir::new("insert-file");
let bolt = dir.file("bolt.dxf", "10,10");
let mut s = Session::new();
// This drawing's own layer PARTS keeps its colour.
s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap();
@@ -128,7 +126,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
assert_eq!(inserts(&s).len(), 2);
// NAME=path defines the block under that name without asking; *path inserts the file's objects.
let pin = file("pin.dxf", "10,10");
let pin = dir.file("pin.dxf", "10,10");
s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap();
assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());
assert!(s.doc().unwrap().block("M8").is_some());
@@ -138,7 +136,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {
assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0));
// JSON: `file` with an optional block name, never a dialog.
let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();
let r = s.execute("insert", &json!({ "file": dir.file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();
assert!(r["handle"].is_string(), "{r}");
assert!(inserts(&s).iter().any(|i| i.0 == "washer"));
@@ -0,0 +1,203 @@
//! Small value changes must not export an imported justified attribute's old baseline.
use cadcraft_doc::{Attrib, Drawing, EntityKind, HAlign, Handle, Text, VAlign};
use cadcraft_engine::{Input, Session};
use cadcraft_geom::Vec3;
use serde_json::json;
const BEFORE: &str = "POWER ADAPTER";
const AFTER: &str = "POWER ADAPTER.";
fn fixture() -> (Session, Handle, Handle) {
let mut s = Session::new();
for (tag, value, y) in [("T", BEFORE, 15), ("U", "ADPT-01", 0)] {
s.execute("attdef", &json!({"tag":tag,"default":value,"at":[0,y],"height":3})).unwrap();
}
s.execute("line", &json!({"points":[[0,-5],[40,-5]]})).unwrap();
let handles: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name":"Equipment","handles":handles,"base":[0,0],"keep":"delete"})).unwrap();
let a = s.execute("insert", &json!({"name":"Equipment","at":[100,200],"scale":2,"rotation":30})).unwrap();
let b = s.execute("insert", &json!({"name":"Equipment","at":[300,200],"attribs":{"T":"Other"}})).unwrap();
let a = Handle::parse_hex(a["handle"].as_str().unwrap()).unwrap();
let b = Handle::parse_hex(b["handle"].as_str().unwrap()).unwrap();
// Set existing document fields directly: this regression does not require Text Options.
s.doc_mut()
.unwrap()
.modify_entity(a, |e| {
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
let t = &mut i.attribs[0].text;
t.insert.z = 7.0;
t.align_pt = Some(t.insert);
t.halign = HAlign::Center;
t.valign = VAlign::Top;
})
.unwrap();
let imported = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let mut s = Session::empty();
s.open_drawing(imported, "Imported", None);
(s, a, b)
}
fn attributes(d: &Drawing, h: Handle) -> Vec<Attrib> {
let EntityKind::Insert(i) = &d.entity(h).unwrap().kind else { panic!("insert") };
i.attribs.clone()
}
fn baseline(d: &Drawing, t: &Text) -> cadcraft_geom::Vec2 {
let style = d.text_style(&t.style).unwrap();
let (h, v) = cadcraft_render::text_align(t.halign, t.valign);
cadcraft_fonts::baseline_start(
&cadcraft_fonts::TextFont::resolve(&style.font),
&t.value,
&cadcraft_fonts::TextParams {
insert: t.insert.xy(),
align_pt: t.align_pt.map(|p| p.xy()),
height: t.height,
rotation: t.rotation,
width_factor: t.width_factor,
oblique: t.oblique,
h,
v,
backwards: style.backwards,
upside_down: style.upside_down,
},
)
}
#[test]
fn all_attribute_value_paths_refresh_small_justified_export_changes() {
for path in ["json", "interactive", "global-json", "global-interactive"] {
let (mut s, a, b) = fixture();
let before = attributes(s.doc().unwrap(), a);
let other = attributes(s.doc().unwrap(), b);
let definition = s.doc().unwrap().block("Equipment").unwrap().clone();
match path {
"json" => {
s.execute("attedit", &json!({"handle":a.hex(),"values":{"t":AFTER}})).unwrap();
}
"interactive" => {
s.start("attedit").unwrap();
s.input(Input::Pick(vec![a])).unwrap();
s.input(Input::Text(AFTER.into())).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.running.is_none());
}
"global-json" => {
s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER."})).unwrap();
}
"global-interactive" => {
s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER.\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
}
_ => panic!("path"),
}
let edited = attributes(s.doc().unwrap(), a);
assert_eq!(edited[0].text.value, AFTER, "{path}");
assert_eq!(edited[0].text.align_pt, before[0].text.align_pt);
assert_eq!(edited[0].text.insert.z, 7.0);
assert_eq!(edited[1], before[1]);
assert_eq!(attributes(s.doc().unwrap(), b), other);
assert_eq!(s.doc().unwrap().block("Equipment").unwrap(), &definition);
let expected = baseline(s.doc().unwrap(), &edited[0].text);
assert!(expected.dist(before[0].text.insert.xy()) > 0.01, "fixture must change width");
for name in ["edited.dxf", "edited.dwg"] {
let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();
let restored = attributes(&reopened, a);
assert!(
restored[0].text.insert.xy().dist(expected) < 1e-8,
"{path} {name}: stale {:?}, expected {expected:?}",
restored[0].text.insert.xy()
);
assert_eq!(restored[0].text.align_pt, before[0].text.align_pt);
assert_eq!(restored[0].text.value, AFTER);
assert_eq!(restored[0].text.insert.z, 7.0);
}
s.undo().unwrap();
assert_eq!(attributes(s.doc().unwrap(), a), before, "{path} undo");
s.redo().unwrap();
assert_eq!(attributes(s.doc().unwrap(), a), edited, "{path} redo");
}
}
#[test]
fn equal_attribute_values_preserve_an_imported_font_baseline() {
let (mut s, a, _) = fixture();
// A small imported-font offset must survive a no-op edit (writer tolerance is deliberate).
s.doc_mut()
.unwrap()
.modify_entity(a, |e| {
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
i.attribs[0].text.insert.x += 0.1;
})
.unwrap();
let before = attributes(s.doc().unwrap(), a);
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":BEFORE}})).unwrap();
assert_eq!(attributes(s.doc().unwrap(), a), before);
s.start("attedit").unwrap();
s.input(Input::Pick(vec![a])).unwrap();
s.input(Input::Enter).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(attributes(s.doc().unwrap(), a), before);
s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER"})).unwrap();
assert_eq!(attributes(s.doc().unwrap(), a), before, "global JSON no-op");
s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(attributes(s.doc().unwrap(), a), before, "global interactive no-op");
}
/// Recomputing a justified baseline must not replace its independent insertion elevation.
#[test]
fn value_edits_preserve_distinct_insertion_and_alignment_elevations_on_export() {
let (mut s, a, _) = fixture();
s.doc_mut()
.unwrap()
.modify_entity(a, |e| {
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
i.attribs[0].text.insert.z = 9.0;
})
.unwrap();
let anchor = attributes(s.doc().unwrap(), a)[0].text.align_pt;
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();
for name in ["elevations.dxf", "elevations.dwg"] {
let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();
let restored = attributes(&reopened, a);
assert_eq!(restored[0].text.insert.z, 9.0, "{name}: insertion elevation");
assert_eq!(restored[0].text.align_pt.map(|p| p.xy()), anchor.map(|p| p.xy()), "{name}: alignment XY");
if name.ends_with(".dxf") {
assert_eq!(restored[0].text.align_pt, anchor, "DXF retains independent alignment elevation");
} else {
// The DWG bridge uses one text elevation for both points.
assert_eq!(restored[0].text.align_pt.unwrap().z, 9.0);
}
}
}
#[test]
fn value_edits_preserve_left_baseline_and_two_point_placements() {
for horizontal in [HAlign::Left, HAlign::Aligned, HAlign::Fit] {
let (mut s, a, _) = fixture();
s.doc_mut()
.unwrap()
.modify_entity(a, |e| {
let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };
let t = &mut i.attribs[0].text;
t.halign = horizontal;
t.valign = VAlign::Baseline;
t.insert = Vec3::new(10.0, 20.0, 7.0);
t.align_pt = (horizontal != HAlign::Left).then_some(Vec3::new(40.0, 30.0, 9.0));
})
.unwrap();
let before = attributes(s.doc().unwrap(), a);
s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();
let mut expected = before;
expected[0].text.value = AFTER.into();
assert_eq!(attributes(s.doc().unwrap(), a), expected);
if horizontal != HAlign::Left {
let reopened = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let restored = attributes(&reopened, a);
assert_eq!(restored[0].text.insert, expected[0].text.insert);
assert_eq!(restored[0].text.align_pt, expected[0].text.align_pt);
assert_eq!(restored[0].text.halign, horizontal);
assert_eq!(restored[0].text.value, AFTER);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//! CHPROP as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back
//! with `entget`).
use cadcraft_engine::Session;
use cadcraft_engine::doc::color::{Color, Rgb};
use cadcraft_engine::doc::{Handle, Lineweight, Transparency};
use serde_json::json;
/// A session with line A (0,0)-(50,0) on layer 0 and line B (0,20)-(50,30) on layer L1, as `a` and
/// `b` in LISP, after `code`; the properties of A then B.
fn run(code: &str) -> (Session, [cadcraft_engine::doc::Common; 2]) {
let mut s = Session::new();
s.doc_mut().unwrap().ensure_layer("L1");
s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap();
s.cmdline("(setq a (entlast))").unwrap();
s.execute("line", &json!({ "points": [[0, 20], [50, 30]] })).unwrap();
s.cmdline("(setq b (entlast))").unwrap();
s.cmdline("(command \"_.CHPROP\" b \"\" \"_LA\" \"L1\" \"\")").unwrap();
s.cmdline(&format!("(progn {code})")).unwrap();
if s.running.is_some() {
s.cancel();
}
let common = {
let d = s.doc().unwrap();
let mut it = d.model.iter().map(|e| e.common.clone());
[it.next().unwrap(), it.next().unwrap()]
};
(s, common)
}
#[test]
fn chprop_sets_each_property_as_autocad() {
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 1 \"\")");
assert_eq!(a.color, Color::Index(1));
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"blue\" \"\")");
assert_eq!(a.color, Color::Index(5));
let (_, [_, b]) = run("(command \"_.CHPROP\" b \"\" \"_C\" \"_BYBLOCK\" \"\")");
assert_eq!(b.color, Color::ByBlock);
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"\")");
assert_eq!(a.color, Color::ByLayer);
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"255,128,0\" \"\")");
assert_eq!(a.color, Color::True(Rgb(255, 128, 0)));
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"L1\" \"\")");
assert_eq!(a.layer, "L1");
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"DASHED\" \"\")");
assert_eq!(a.linetype, "DASHED");
// Not loaded yet: loaded and given, as AutoCAD loads it from acadiso.lin.
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"PHANTOM2\" \"\")");
assert_eq!(a.linetype, "PHANTOM2");
assert!(s.doc().unwrap().linetype("PHANTOM2").is_some());
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_S\" 2.5 \"\")");
assert_eq!(a.ltscale, 2.5);
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LW\" 0.33 \"\")");
assert_eq!(a.lineweight, Lineweight::Mm100(35));
assert!(s.log.iter().any(|l| l == "Lineweight rounded to nearest valid value of 0.35 mm"), "{:?}", s.log);
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_T\" 5.0 \"\")");
assert_eq!(a.thickness, 5.0);
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 50 \"\")");
assert_eq!(a.transparency, Transparency::Percent(50));
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" \"_BYBLOCK\" \"\")");
assert_eq!(a.transparency, Transparency::ByBlock);
// Several objects and several properties in one go.
let (_, [a, b]) = run("(command \"_.CHPROP\" a b \"\" \"_C\" 3 \"_LA\" \"L1\" \"_LW\" 0.5 \"\")");
for c in [a, b] {
assert_eq!((c.color, c.layer.as_str(), c.lineweight), (Color::Index(3), "L1", Lineweight::Mm100(50)));
}
let (_, [a, _]) = run(
"(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"_LT\" \"_BYBLOCK\" \"_LW\" \"_BYLAYER\" \"\")",
);
assert_eq!((a.color, a.linetype.as_str(), a.lineweight), (Color::ByLayer, "ByBlock", Lineweight::ByLayer));
}
#[test]
fn chprop_refuses_what_autocad_refuses() {
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"KHONGCO\" \"\")");
assert_eq!(a.layer, "0");
assert!(s.log.iter().any(|l| l == "Cannot find layer \"KHONGCO\"."), "{:?}", s.log);
for n in [0, 256, 300] {
let (s, [a, _]) = run(&format!("(command \"_.CHPROP\" a \"\" \"_C\" {n} \"\")"));
assert_eq!(a.color, Color::ByLayer, "{n}");
assert!(s.log.iter().any(|l| l == "Invalid color number."), "{n}: {:?}", s.log);
}
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"300,0,0\" \"\")");
assert_eq!(a.color, Color::ByLayer);
assert!(s.log.iter().any(|l| l == "Requires three integers from 0 to 255, separated by commas."), "{:?}", s.log);
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 95 \"\")");
assert_eq!(a.transparency, Transparency::ByLayer);
assert!(s.log.iter().any(|l| l == "Requires ByLayer, ByBlock or an integer from 0 to 90."), "{:?}", s.log);
// `L` is LAyer, LType, ltScale or LWeight: none of them.
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_L\")");
assert_eq!(a.layer, "0");
assert!(s.log.iter().any(|l| l == "Ambiguous response, please clarify..."), "{:?}", s.log);
}
/// Line a on layer 0 and lines b and c on the locked layer L1 (`a`, `b` and `c` in LISP); their
/// handles.
fn locked_drawing() -> (Session, Vec<Handle>) {
let mut s = Session::new();
s.doc_mut().unwrap().ensure_layer("L1");
for (v, y) in [("a", 0), ("b", 20), ("c", 40)] {
s.execute("line", &json!({ "points": [[0, y], [50, y]] })).unwrap();
s.cmdline(&format!("(setq {v} (entlast))")).unwrap();
}
s.cmdline("(command \"_.CHPROP\" b c \"\" \"_LA\" \"L1\" \"\")").unwrap();
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
s.log.clear();
let hs = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
(s, hs)
}
fn colors(s: &Session) -> Vec<Color> {
s.doc().unwrap().model.iter().map(|e| e.common.color).collect()
}
/// AutoCAD 2024 leaves objects on locked layers out at "Select objects" and says how many after
/// what was found; with none left, Enter ends the command.
#[test]
fn chprop_leaves_objects_on_locked_layers_alone() {
// The colours of a, b and c after `code`, run with `pickfirst` selected.
let after = |code: &str, pickfirst: &[usize]| {
let (mut s, hs) = locked_drawing();
s.set_selection(pickfirst.iter().map(|i| hs[*i]).collect());
s.cmdline(code).unwrap();
(colors(&s), s.log.clone(), s.running.is_some())
};
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd b (ssadd a)) \"\" \"_C\" 3 \"\")", &[]);
assert_eq!(c, [Color::Index(3), Color::ByLayer, Color::ByLayer]);
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd c (ssadd b (ssadd a))) \"\" \"_C\" 4 \"\")", &[]);
assert_eq!(c, [Color::Index(4), Color::ByLayer, Color::ByLayer]);
assert!(log.iter().any(|l| l == "2 were on a locked layer."), "{log:?}");
// Nothing left: Enter ends the command.
let (_, log, running) = after("(command \"_.CHPROP\" b \"\")", &[]);
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
assert!(!running);
// The pickfirst selection loses them too; with none of it left, CHPROP asks for objects.
let (c, log, _) = after("(command \"_.CHPROP\" \"_C\" 5 \"\")", &[0, 1]);
assert_eq!(c, [Color::Index(5), Color::ByLayer, Color::ByLayer]);
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
let (c, _, _) = after("(command \"_.CHPROP\" a \"\" \"_C\" 6 \"\")", &[1]);
assert_eq!(c, [Color::Index(6), Color::ByLayer, Color::ByLayer]);
// The JSON form leaves them out as well, and says how many.
let (mut s, hs) = locked_drawing();
let r = s.execute("chprop", &json!({ "handles": [hs[0].hex(), hs[1].hex()], "color": 3 })).unwrap();
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}");
let r = s.execute("chprop", &json!({ "handles": [hs[2].hex()], "color": 4 })).unwrap();
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
assert_eq!(colors(&s), [Color::Index(3), Color::ByLayer, Color::ByLayer]);
}
+24
View File
@@ -0,0 +1,24 @@
//! JSON COPY refuses a `count` that is not an integer from 1 to 10000 instead of clamping it.
use cadcraft_engine::Session;
use serde_json::{Value, json};
fn entities(s: &Session) -> usize {
s.doc().map(|d| d.model.iter().count()).unwrap_or(0)
}
#[test]
fn invalid_copy_count_is_refused() {
let mut s = Session::new();
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let h = r["handles"][0].clone();
let before = entities(&s);
let bad: [Value; 7] = [json!(0), json!(-3), json!(2.5), json!("abc"), json!(null), json!(10001), json!(50000)];
for c in bad {
let r = s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": c}));
assert!(r.is_err(), "count {c} should be refused");
assert_eq!(entities(&s), before, "count {c} changed the drawing");
}
s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": 3})).unwrap();
assert_eq!(entities(&s), before + 3);
}
+27
View File
@@ -99,6 +99,7 @@ fn jogged_radius() {
let (dm, g) = dim_of(&s, &h);
let j = dm.marks.jogged.clone().unwrap();
assert_eq!(g.value, "R50.0000", "the true radius");
assert!((dm.measurement - 50.0).abs() < 1e-9, "the stored measurement is the radius, got {} (#2421)", dm.measurement);
assert!(j.center.xy().dist(Vec2::new(20.0, -10.0)) < 1e-9);
// The line runs from the arrowhead on the circle to the override, with a 45° jog (DIMJOGANG).
let tip = Vec2::new(1.0, 1.0).normalized() * 50.0;
@@ -257,3 +258,29 @@ fn marks_follow_move_and_rotate_and_hostile_values_do_not_panic() {
.unwrap();
let _ = dim_of(&s, &h);
}
/// A dimension made in the session reports its measurement at once, not 0.0 until it is saved
/// and reopened (#2421).
#[test]
fn a_new_dimension_reports_its_measurement() {
let mut s = Session::new();
let measurement = |s: &Session, h: &str| dim_of(s, h).0.measurement;
let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap());
assert!((measurement(&s, &horizontal) - 100.0).abs() < 1e-9, "{}", measurement(&s, &horizontal));
let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-10, 20] })).unwrap());
assert!((measurement(&s, &vertical) - 37.5).abs() < 1e-9, "{}", measurement(&s, &vertical));
}
/// A new dimension's text position is known at once, as it is after save and reopen (#2421).
#[test]
fn a_new_dimension_reports_its_text_position() {
let mut s = Session::new();
let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap());
let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-20, 18] })).unwrap());
for h in [&horizontal, &vertical] {
let (dm, g) = dim_of(&s, h);
assert!(dm.text_mid.xy().dist(g.text_pos) < 1e-9, "stored {:?}, drawn at {:?}", dm.text_mid, g.text_pos);
assert!(dm.text_mid.xy().dist(Vec2::ZERO) > 1.0, "still at the origin: {:?}", dm.text_mid);
}
assert!((dim_of(&s, &horizontal).0.text_mid.x - 50.0).abs() < 1e-9);
}
+77 -1
View File
@@ -1,4 +1,4 @@
//! Command-line INSERT: X/Y scale prompts and the Basepoint/Scale/Rotate options (#245).
//! Command-line INSERT: X/Y/Z scale prompts and the Basepoint/Scale/X/Y/Z/Rotate options (#245).
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
@@ -53,3 +53,79 @@ fn script_insert_reads_x_and_y_scale_then_rotation_and_honours_its_options() {
s.execute("insert", &json!({ "name": "B1", "at": [0, 80], "scale": [2, 3], "basePoint": [10, 0] })).unwrap();
assert_eq!(inserts(&s).last(), Some(&(-20.0, 80.0, 2.0, 3.0, 0.0)));
}
/// (x, y, x scale, y scale, z scale, rotation in degrees) of the last block reference.
fn last_insert(s: &Session) -> (f64, f64, f64, f64, f64, f64) {
let r = |v: f64| (v * 1e9).round() / 1e9;
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some((r(i.insert.x), r(i.insert.y), r(i.scale.x), r(i.scale.y), r(i.scale.z), r(i.rotation.to_degrees()))),
_ => None,
})
.last()
.unwrap()
}
/// The options of AutoCAD 2024's -INSERT, each run as AutoLISP's `command` gives it (accoreconsole,
/// the block reference read back with `entget`): X/Y/Z presets set that axis alone and skip the
/// scale prompts, XYZ asks Z too, a Z factor not asked is the X factor's size, and a Basepoint is
/// picked on the block dropped at the cursor (the origin in a script).
#[test]
fn insert_options_match_autocad() {
// A line (0,0)-(10,0) and a circle at (5,5) r 3, around `base`, as block `name`.
let block = |s: &mut Session, name: &str, base: [f64; 2], unit: f64| {
let at = |x: f64, y: f64| [base[0] + x * unit, base[1] + y * unit];
s.execute("line", &json!({ "points": [at(0.0, 0.0), at(10.0, 0.0)] })).unwrap();
s.execute("circle", &json!({ "center": at(5.0, 5.0), "radius": 3.0 * unit })).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect();
s.execute("block", &json!({ "name": name, "base": base, "keep": "delete", "handles": hs })).unwrap();
};
let cases: [(&str, [f64; 2], f64, &str, (f64, f64, f64, f64, f64, f64)); 17] = [
// X -1, Y as X: Z +1.
("B3", [612345.678, 2398765.432], 1.0, "\"B3\" '(612365.678 2398775.432) -1 \"\" 0", (612365.678, 2398775.432, -1.0, -1.0, 1.0, 0.0)),
("B4", [0.0, 0.0], 1.0, "\"B4\" \"_S\" 2.5 '(20.0 10.0) 45", (20.0, 10.0, 2.5, 2.5, 2.5, 45.0)),
("B5", [0.0, 0.0], 1.0, "\"B5\" \"_R\" 60 '(20.0 10.0) 2 \"\"", (20.0, 10.0, 2.0, 2.0, 2.0, 60.0)),
// X alone: the next number is the rotation.
("B6", [0.5, 0.25], 0.001, "\"B6\" \"_X\" 2 '(0.52 0.26) 3", (0.52, 0.26, 2.0, 1.0, 1.0, 3.0)),
(
"B7",
[612345.678, 2398765.432],
1.0,
"\"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0",
(15.0, 5.0, 1.0, 1.0, 1.0, 0.0),
),
("B8", [0.0, 0.0], 1.0, "\"B8\" '(20.0 10.0) \"_XYZ\" 2 3 4 0", (20.0, 10.0, 2.0, 3.0, 4.0, 0.0)),
("B9", [0.0, 0.0], 1.0, "\"B9\" '(20.0 10.0) \"_C\" '(26.0 14.0) 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
// Z is stored as a size unless typed at XYZ's Z prompt: Scale -2, XYZ -2 with Enter at Y
// and Z, and a Z preset of -3 all give +; XYZ's -4 stays.
("C1", [0.0, 0.0], 1.0, "\"C1\" \"_S\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
("C2", [0.0, 0.0], 1.0, "\"C2\" '(20.0 10.0) \"_XYZ\" -2 \"\" \"\" 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
("C5", [0.0, 0.0], 1.0, "\"C5\" \"_Z\" -3 '(20.0 10.0) 0", (20.0, 10.0, 1.0, 1.0, 3.0, 0.0)),
("C6", [0.0, 0.0], 1.0, "\"C6\" '(20.0 10.0) \"_XYZ\" 2 3 -4 0", (20.0, 10.0, 2.0, 3.0, -4.0, 0.0)),
("C7", [0.0, 0.0], 1.0, "\"C7\" \"_X\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, 1.0, 1.0, 0.0)),
// Scale and the axis presets in turn: the later one wins for its axes.
("C8", [0.0, 0.0], 1.0, "\"C8\" \"_S\" -3 \"_X\" 2 '(20.0 10.0) 0", (20.0, 10.0, 2.0, -3.0, 3.0, 0.0)),
("C9", [0.0, 0.0], 1.0, "\"C9\" \"_X\" 2 \"_S\" -3 '(20.0 10.0) 0", (20.0, 10.0, -3.0, -3.0, 3.0, 0.0)),
("CA", [0.0, 0.0], 1.0, "\"CA\" \"_S\" 2 \"_Z\" -5 '(20.0 10.0) 0", (20.0, 10.0, 2.0, 2.0, 5.0, 0.0)),
// XYZ then a corner (by Corner or straight away) still asks Z, which defaults to X's size.
("C3", [0.0, 0.0], 1.0, "\"C3\" '(20.0 10.0) \"_XYZ\" \"_C\" '(26.0 14.0) \"\" 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
("C4", [0.0, 0.0], 1.0, "\"C4\" '(20.0 10.0) \"_XYZ\" '(14.0 14.0) \"\" 0", (20.0, 10.0, -6.0, 4.0, 6.0, 0.0)),
];
for (name, base, unit, args, want) in cases {
let mut s = Session::new();
block(&mut s, name, base, unit);
s.cmdline(&format!("(command \"_.-INSERT\" {args})")).unwrap();
assert!(s.running.is_none(), "{name}: {}", s.prompt_text());
assert_eq!(last_insert(&s), want, "{name}");
}
// Basepoint is picked on the block dropped at the cursor: with the cursor at (100, 50)
// instead of the origin, B7's call inserts 100 and 50 further on.
let mut s = Session::new();
block(&mut s, "B7", [612345.678, 2398765.432], 1.0);
s.cursor = cadcraft_engine::geom::Vec2::new(100.0, 50.0);
s.cmdline("(command \"_.-INSERT\" \"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0)").unwrap();
assert_eq!(last_insert(&s), (115.0, 55.0, 1.0, 1.0, 1.0, 0.0));
}
@@ -159,3 +159,16 @@ fn layer_delete_clears_viewport_overrides_for_the_layer() {
s.execute("layer.new", &json!({ "name": "C" })).unwrap();
assert!(viewport_lists(&s).iter().all(|(f, _)| !f.contains(&"C".to_string())));
}
#[test]
fn renaming_a_layer_repoints_viewport_lists() {
let mut s = drawing();
assert_eq!(viewport_lists(&s), vec![(vec!["A".to_string(), "C".to_string()], vec!["B".to_string()])]);
s.execute("rename", &json!({ "table": "layer", "from": "A", "to": "Murs" })).unwrap();
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])]);
// layer.set's newName form (what the Properties palette sends) goes through the same path.
s.execute("layer.set", &json!({ "name": "B", "newName": "Cloisons" })).unwrap();
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["Cloisons".to_string()])]);
s.undo().unwrap();
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])], "undo brings the old name back");
}
+6
View File
@@ -10,6 +10,7 @@ mod attedit_prompts;
mod attrib_modes;
mod attrib_props;
mod attribute_tools;
mod attribute_value_baseline;
mod block_description;
mod block_keep;
mod block_redefine;
@@ -18,9 +19,11 @@ mod break_closed_polyline;
mod break_join_locked_layers;
mod break_keeps_z;
mod burst;
mod chprop_command;
mod compare_export;
mod constraint_geometry;
mod constraint_infer;
mod copy_count;
mod count;
mod dash_block;
mod data_extraction;
@@ -85,6 +88,7 @@ mod offset_move;
mod options_profile;
mod overkill;
mod parallel_display_list;
mod pedit_multiple;
mod pedit_splinedit;
mod pick_text;
mod pickdrag;
@@ -123,6 +127,7 @@ mod snap_nearest;
mod snap_overrides;
mod snap_tracking;
mod snaps_more;
mod solid_command;
mod space_is_enter;
mod spline_degree;
mod spline_options;
@@ -142,6 +147,7 @@ mod undo_layout_tab;
mod viewport_clip;
mod vplayer_prompts;
mod vports;
mod wipeout_grips;
mod xdata_follows_entity;
mod zoom_keywords;
mod zoom_viewport;
+197
View File
@@ -0,0 +1,197 @@
//! #2182: PEDIT Multiple at the command line, as AutoCAD: select several objects, turn lines and
//! arcs into polylines, then each option applies to all of them; Join joins them with each other.
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
fn polylines(s: &Session) -> Vec<(usize, bool, f64)> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some((p.vertices.len(), p.closed, p.const_width)) } else { None })
.collect()
}
fn count(s: &Session) -> usize {
s.doc().unwrap().model.len()
}
fn lines(s: &mut Session, pts: &[[f64; 4]]) {
for p in pts {
s.execute("line", &json!({ "points": [[p[0], p[1]], [p[2], p[3]]] })).unwrap();
}
}
#[test]
fn join_turns_four_lines_into_one_closed_polyline() {
let mut s = Session::new();
lines(&mut s, &[[0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [10.0, 5.0, 0.0, 5.0], [0.0, 5.0, 0.0, 0.0]]);
for t in ["PEDIT", "M", "ALL", "", "Y", "J", "", ""] {
s.cmdline(t).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(polylines(&s), vec![(4, true, 0.0)]);
assert_eq!(count(&s), 1);
assert!(s.log.iter().any(|l| l.contains("3 segments added to polyline")), "{:?}", s.log);
}
#[test]
fn join_bridges_gaps_within_the_fuzz_distance_only() {
// Two chains with a 0.5 gap between them.
let gap = |s: &mut Session| lines(s, &[[0.0, 0.0, 10.0, 0.0], [10.5, 0.0, 20.0, 0.0]]);
let mut s = Session::new();
gap(&mut s);
for t in ["PE", "M", "ALL", "", "Y", "J", "0", ""] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s).len(), 2, "no fuzz: still two");
let mut s = Session::new();
gap(&mut s);
for t in ["PE", "M", "ALL", "", "Y", "J", "1", ""] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s).len(), 1, "a fuzz of 1 joins them");
}
#[test]
fn options_apply_to_all_and_undo_takes_one_back() {
let mut s = Session::new();
s.cmdline("pline 0,0 10,0 10,10").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 20,0 30,0 30,10").unwrap();
s.cmdline("").unwrap();
for t in ["PEDIT", "M", "ALL", "", "W", "0.5", "C"] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s), vec![(3, true, 0.5), (3, true, 0.5)], "width and close on both");
s.cmdline("U").unwrap();
assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "Undo opens them again");
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
#[test]
fn lines_left_out_when_not_converted() {
let mut s = Session::new();
s.cmdline("pline 0,0 10,0 10,10").unwrap();
s.cmdline("").unwrap();
lines(&mut s, &[[20.0, 0.0, 30.0, 0.0]]);
for t in ["PEDIT", "M", "ALL", "", "N", "C", ""] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s), vec![(3, true, 0.0)], "only the polyline is edited");
assert!(s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Line(_))), "the line stays a line");
}
#[test]
fn objects_selected_first_go_straight_to_multiple() {
let mut s = Session::new();
s.cmdline("pline 0,0 10,0 10,10").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 20,0 30,0 30,10").unwrap();
s.cmdline("").unwrap();
let all: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
s.set_selection(all);
for t in ["PEDIT", "W", "0.5", ""] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "no second selection asked for");
assert!(s.running.is_none());
}
#[test]
fn decurve_after_fit_gives_back_the_vertices() {
let mut s = Session::new();
s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 0,20 10,20 10,30").unwrap();
s.cmdline("").unwrap();
for t in ["PEDIT", "M", "ALL", "", "S"] {
s.cmdline(t).unwrap();
}
assert!(polylines(&s).iter().all(|p| p.0 > 4), "Spline adds vertices: {:?}", polylines(&s));
s.cmdline("D").unwrap();
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "Decurve gives back each one's vertices");
s.cmdline("").unwrap();
}
#[test]
fn decurve_still_gives_back_the_vertices_after_a_join_that_joins_nothing_or_is_undone() {
let mut s = Session::new();
s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 0,20 10,20 10,30").unwrap();
s.cmdline("").unwrap();
// Join with nothing in reach joins nothing: Decurve still gives back the vertices.
for t in ["PEDIT", "M", "ALL", "", "S", "J", "0", "D"] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after a Join that joined nothing");
// Spline, a Join that joins them, Undo: Decurve gives back the vertices again.
for t in ["S", "J", "100", "U", "D"] {
s.cmdline(t).unwrap();
}
assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after an undone Join");
s.cmdline("").unwrap();
}
fn widths(s: &Session) -> Vec<f64> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p) } else { None })
.flat_map(|p| p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]))
.collect()
}
#[test]
fn convert_asks_again_for_an_answer_that_is_not_yes_or_no() {
let mut s = Session::new();
lines(&mut s, &[[0.0, 0.0, 10.0, 0.0]]);
for t in ["PEDIT", "M", "ALL", ""] {
s.cmdline(t).unwrap();
}
s.cmdline("foo").unwrap();
assert!(s.prompt_text().contains("Convert"), "{}", s.prompt_text());
assert_eq!(polylines(&s), vec![], "nothing converted on a wrong answer");
s.cmdline("Y").unwrap();
assert_eq!(polylines(&s).len(), 1);
s.cmdline("").unwrap();
// Single-polyline PEDIT asks again too.
lines(&mut s, &[[0.0, 5.0, 10.0, 5.0]]);
let l = s.doc().unwrap().model.last().unwrap().handle;
s.set_selection(vec![l]);
s.cmdline("PEDIT").unwrap();
s.cmdline("foo").unwrap();
assert!(s.prompt_text().contains("turn it into one"), "{}", s.prompt_text());
s.cmdline("N").unwrap();
assert!(s.running.is_none());
}
#[test]
fn decurve_after_width_keeps_the_new_width() {
let mut s = Session::new();
for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] {
s.cmdline(t).unwrap();
}
assert!(widths(&s).iter().any(|w| *w > 0.0), "{:?}", widths(&s));
for t in ["PEDIT", "M", "ALL", "", "S", "W", "0", "D", ""] {
s.cmdline(t).unwrap();
}
assert!(widths(&s).iter().all(|w| *w == 0.0), "Multiple: {:?}", widths(&s));
let mut s = Session::new();
for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] {
s.cmdline(t).unwrap();
}
let h = s.doc().unwrap().model.last().unwrap().handle;
s.set_selection(vec![h]);
for t in ["PEDIT", "S", "W", "0", "D", ""] {
s.cmdline(t).unwrap();
}
assert!(widths(&s).iter().all(|w| *w == 0.0), "single: {:?}", widths(&s));
}
+115
View File
@@ -0,0 +1,115 @@
//! SOLID (2D solids) as AutoCAD 2024 draws them from AutoLISP's `command` (accoreconsole, every
//! solid read back with `entget`).
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// The corners of every solid, in drawing order.
fn solids(s: &Session) -> Vec<[Vec2; 4]> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Solid(so) => Some(so.corners.map(|c| Vec2::new(c.x, c.y))),
_ => None,
})
.collect()
}
#[test]
fn solid_chains_its_corners_as_autocad() {
let p = |x: f64, y: f64| (x, y);
// (points, Enters after them, each solid as indexes into the points)
let cases: [(Vec<(f64, f64)>, usize, Vec<[usize; 4]>); 7] = [
// Enter at the fourth point: a triangle repeating its third corner.
(
vec![p(19.451648928934738, -21.25585202371201), p(34.61693931374715, -3.839951210926415), p(14.55170247613551, -36.00688291519149)],
1,
vec![[0, 1, 2, 2]],
),
// Two solids, the second on the first one's last two corners.
(
vec![
p(612336.9748522255, 2398742.6897319513),
p(612351.3507889743, 2398794.3456611084),
p(612371.7043989347, 2398771.9517326546),
p(612379.1755178814, 2398787.9912080113),
p(612308.5250187548, 2398780.014694856),
p(612356.1118879627, 2398726.474738389),
],
1,
vec![[0, 1, 2, 3], [2, 3, 4, 5]],
),
// A solid, then a triangle.
(
vec![
p(15.962585354071173, 3.3526103822549587),
p(-0.8095657933516947, 13.123790321338404),
p(-19.412229808271626, -27.60123059493223),
p(-33.66123919971652, 12.18177290955613),
p(14.002713873827737, -8.925735622823005),
],
1,
vec![[0, 1, 2, 3], [2, 3, 4, 4]],
),
// Three solids, the last a triangle.
(
vec![
p(29.3005193409103, -24.874809106460187),
p(-21.460879071052776, 35.356609837704795),
p(-31.347336242570112, -21.756568658673665),
p(-39.08169761088666, 23.588062542950155),
p(23.0763904721503, 10.219805555956789),
p(-22.701751810515756, 22.76615204356692),
p(4.338066830902321, -37.50391578060281),
],
1,
vec![[0, 1, 2, 3], [2, 3, 4, 5], [4, 5, 6, 6]],
),
// Enter twice: the triangle, then Enter at the third point ends the command.
(
vec![p(612325.6793323631, 2398757.396299568), p(612370.1009101832, 2398796.2224137546), p(612378.8199480829, 2398804.1981022176)],
2,
vec![[0, 1, 2, 2]],
),
// Corners stay in the order given (a "Z" order makes a rectangle).
(vec![p(0.0, 0.0), p(30.0, 0.0), p(0.0, 20.0), p(30.0, 20.0)], 1, vec![[0, 1, 2, 3]]),
// Enter after two points: nothing.
(vec![p(-34769.59558954922, 81960.0236700466), p(-35927.22173307072, 79264.64372697374)], 1, vec![]),
];
let same = |a: Vec2, b: (f64, f64)| (a.x - b.0).abs() <= 1e-9 * b.0.abs().max(1.0) && (a.y - b.1).abs() <= 1e-9 * b.1.abs().max(1.0);
for (pts, enters, want) in cases {
let mut s = Session::new();
let args: Vec<String> = pts.iter().map(|(x, y)| format!("'({x:?} {y:?})")).chain(std::iter::repeat_n("\"\"".to_string(), enters)).collect();
s.cmdline(&format!("(command \"_.SOLID\" {})", args.join(" "))).unwrap();
// One Enter leaves the command at "Specify third point", as AutoCAD's does (its log then
// shows the measuring routine ending it).
if s.running.is_some() {
assert!(s.prompt_text().contains("Specify third point"), "{pts:?}: {}", s.prompt_text());
s.cancel();
}
let got = solids(&s);
assert_eq!(got.len(), want.len(), "{pts:?}: {got:?}");
for (g, w) in got.iter().zip(&want) {
assert!(g.iter().zip(w).all(|(c, &i)| same(*c, pts[i])), "{pts:?}: {got:?}");
}
}
}
#[test]
fn solid_json_chains_the_same_way() {
let mut s = Session::new();
s.execute("solid", &json!({ "points": [[0, 0], [10, 0], [0, 5], [10, 5], [0, 10]] })).unwrap();
let got = solids(&s);
let v = |x: f64, y: f64| Vec2::new(x, y);
assert_eq!(got, vec![[v(0.0, 0.0), v(10.0, 0.0), v(0.0, 5.0), v(10.0, 5.0)], [v(0.0, 5.0), v(10.0, 5.0), v(0.0, 10.0), v(0.0, 10.0)]]);
assert!(s.execute("solid", &json!({ "points": [[0, 0], [1, 0]] })).is_err());
// The alias.
s.cmdline("so 20,0 30,0 20,5").unwrap();
s.cmdline("").unwrap();
s.cmdline("").unwrap();
assert_eq!(solids(&s).len(), 3);
}
+227
View File
@@ -0,0 +1,227 @@
//! WIPEOUT's optional repeated closing point is not a second editable corner (#2256).
//! Authored contours keep their grips and edit results through DXF/DWG saves, both via the
//! command API and the interactive grip machine, without changing the file's explicit closure.
use cadcraft_doc::{Drawing, EntityKind, Handle, LwPolyline, Space};
use cadcraft_engine::{
Input, Session,
grips::{GripMode, stretch_grip},
};
use cadcraft_geom::{PolyVertex, Vec2};
use cadcraft_io::{SaveOptions, Version};
use serde_json::json;
fn fixture(explicit: bool, reversed: bool) -> (Drawing, Handle, Vec<Vec2>) {
let mut points = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)];
if reversed {
points.reverse();
}
let mut input = points.clone();
if explicit {
input.push(points[0]);
}
let mut session = Session::new();
let input: Vec<_> = input.iter().map(|p| [p.x, p.y]).collect();
let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
(session.doc().unwrap().clone(), handle, points)
}
fn points_equal(actual: &[Vec2], expected: &[Vec2]) {
assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}");
for (a, b) in actual.iter().zip(expected) {
assert!(a.near(*b, 1e-12), "{actual:?} != {expected:?}");
}
}
fn assert_boundary(drawing: &Drawing, handle: Handle, points: &[Vec2], explicit: bool) {
let EntityKind::Wipeout(w) = &drawing.entity(handle).unwrap().kind else { panic!("WIPEOUT lost") };
let mut expected = points.to_vec();
if explicit {
expected.push(points[0]);
}
points_equal(&w.boundary, &expected);
}
fn move_grip(session: &mut Session, handle: Handle, index: usize, to: Vec2, interactive: bool) {
session.set_selection(vec![handle]);
if interactive {
session.start_grip_edit(&[(handle, index)]).unwrap();
session.input(Input::Point(to)).unwrap();
assert!(!session.prompting());
} else {
session.grip_edit(handle, index, to, GripMode::Stretch).unwrap();
}
}
fn edit(drawing: Drawing, handle: Handle, points: &[Vec2], explicit: bool, index: usize, interactive: bool) -> Drawing {
let mut session = Session::new();
session.open_drawing(drawing, "wipeout", None);
points_equal(&session.grips_of(handle).unwrap(), points);
let to = points[index] + Vec2::new(2.0, 1.0);
let mut expected = points.to_vec();
expected[index] = to;
move_grip(&mut session, handle, index, to, interactive);
assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
points_equal(&session.grips_of(handle).unwrap(), &expected);
session.execute("undo", &json!({})).unwrap();
assert_boundary(session.doc().unwrap(), handle, points, explicit);
session.execute("redo", &json!({})).unwrap();
assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
session.doc().unwrap().clone()
}
fn check(format: &str, interactive: bool) {
for explicit in [false, true] {
for reversed in [false, true] {
let (drawing, handle, points) = fixture(explicit, reversed);
for index in 0..points.len() {
edit(drawing.clone(), handle, &points, explicit, index, interactive);
}
for version in Version::ALL {
for binary in [false, true].into_iter().filter(|binary| !*binary || format == "wipeout.dxf") {
let options = SaveOptions { version: Some(version), binary };
let mut current = drawing.clone();
for _pass in 0..2 {
let bytes = cadcraft_io::write_with(&current, format, &options).unwrap();
current = cadcraft_io::read(&bytes, format).unwrap();
assert!(current.read_notes.is_empty() && current.skipped_records.is_empty());
assert_boundary(&current, handle, &points, true);
for index in 0..points.len() {
let edited = edit(current.clone(), handle, &points, true, index, interactive);
let mut expected = points.clone();
expected[index] += Vec2::new(2.0, 1.0);
let bytes = cadcraft_io::write_with(&edited, format, &options).unwrap();
let back = cadcraft_io::read(&bytes, format).unwrap();
assert_boundary(&back, handle, &expected, true);
}
}
}
}
}
}
}
#[test]
fn dxf_api_grips_survive_roundtrip() {
check("wipeout.dxf", false);
}
#[test]
fn dxf_interactive_grips_survive_roundtrip() {
check("wipeout.dxf", true);
}
#[test]
fn dwg_api_grips_survive_roundtrip() {
check("wipeout.dwg", false);
}
#[test]
fn dwg_interactive_grips_survive_roundtrip() {
check("wipeout.dwg", true);
}
#[test]
fn polyline_closedness_is_not_inferred_from_coincident_endpoints() {
for closed in [false, true] {
for repeated in [false, true] {
let mut points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
if repeated {
points.push(points[0]);
}
let polyline = LwPolyline {
vertices: points.iter().copied().map(PolyVertex::new).collect(),
closed,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
};
let kind = EntityKind::LwPolyline(polyline);
let segments = if repeated || closed { 3 } else { 2 };
assert_eq!(kind.prims().len(), segments);
assert_eq!(kind.grips().len(), points.len() + segments);
let to = Vec2::new(2.0, 3.0);
let EntityKind::LwPolyline(edited) = stretch_grip(&kind, 0, to).unwrap() else { panic!("polyline") };
assert_eq!(edited.closed, closed);
let mut expected = points.clone();
expected[0] = to;
assert_eq!(edited.vertices.iter().map(|v| v.p).collect::<Vec<_>>(), expected);
// WIPEOUT conversion requires the closed flag, even with coincident endpoints.
let mut session = Session::new();
let handle = session.doc_mut().unwrap().add(&Space::Model, Default::default(), kind).unwrap();
let result = session.execute("wipeout", &json!({"handle": handle.hex()}));
assert_eq!(result.is_ok(), closed);
if let Ok(result) = result {
let wipeout = Handle::parse_hex(result["handle"].as_str().unwrap()).unwrap();
assert_boundary(session.doc().unwrap(), wipeout, &points[..3], repeated);
points_equal(&session.grips_of(wipeout).unwrap(), &points[..3]);
} else {
assert_eq!(session.doc().unwrap().model.len(), 1);
}
}
}
}
#[test]
fn coincident_endpoints_remain_separately_editable_after_save() {
let original = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
for explicit in [false, true] {
for interactive in [false, true] {
for index in [0, 3] {
let other = 3 - index;
let mut input: Vec<_> = original.iter().map(|p| [p.x, p.y]).collect();
if explicit {
input.push(input[0]);
}
let mut session = Session::new();
let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
let mut coincident = original;
coincident[index] = original[other];
move_grip(&mut session, handle, index, coincident[index], interactive);
points_equal(&session.grips_of(handle).unwrap(), &coincident);
assert_boundary(session.doc().unwrap(), handle, &coincident, true);
session.undo().unwrap();
assert_boundary(session.doc().unwrap(), handle, &original, explicit);
session.execute("redo", &json!({})).unwrap();
points_equal(&session.grips_of(handle).unwrap(), &coincident);
let collapsed = session.doc().unwrap().clone();
for version in Version::ALL {
for (format, binary) in [("wipeout.dxf", false), ("wipeout.dxf", true), ("wipeout.dwg", false)] {
let options = SaveOptions { version: Some(version), binary };
let bytes = cadcraft_io::write_with(&collapsed, format, &options).unwrap();
let back = cadcraft_io::read(&bytes, format).unwrap();
session.open_drawing(back, "coincident corners", None);
points_equal(&session.grips_of(handle).unwrap(), &coincident);
assert_boundary(session.doc().unwrap(), handle, &coincident, true);
let mut separated = coincident;
separated[other] = Vec2::new(-2.0, 4.0);
move_grip(&mut session, handle, other, separated[other], interactive);
points_equal(&session.grips_of(handle).unwrap(), &separated);
assert_boundary(session.doc().unwrap(), handle, &separated, true);
let bytes = cadcraft_io::write_with(session.doc().unwrap(), format, &options).unwrap();
let back = cadcraft_io::read(&bytes, format).unwrap();
assert_boundary(&back, handle, &separated, true);
}
}
}
}
}
}
#[test]
fn multiple_hot_grips_keep_the_closing_point_attached() {
let (drawing, handle, mut points) = fixture(true, false);
let mut session = Session::new();
session.open_drawing(drawing, "wipeout", None);
session.set_selection(vec![handle]);
session.start_grip_edit(&[(handle, 0), (handle, 1)]).unwrap();
let delta = Vec2::new(2.0, 1.0);
session.input(Input::Point(points[0] + delta)).unwrap();
points[0] += delta;
points[1] += delta;
assert_boundary(session.doc().unwrap(), handle, &points, true);
points_equal(&session.grips_of(handle).unwrap(), &points);
}
+27 -13
View File
@@ -6,16 +6,12 @@ use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Hatch};
use serde_json::json;
const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";
#[path = "support/temp_dir.rs"]
mod temp_dir;
/// A user file in the test's temp folder; returns its path.
fn file(name: &str, text: &str) -> String {
let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(name);
std::fs::write(&p, text).unwrap();
p.to_string_lossy().into_owned()
}
use temp_dir::TempDir;
const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";
fn linetype_pattern(s: &Session, name: &str) -> Option<Vec<f64>> {
s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect())
@@ -46,7 +42,8 @@ fn linetype_json_loads_named_linetypes_from_file_text() {
#[test]
fn linetype_json_reads_a_lin_file_by_path() {
let path = file("mine.lin", LIN);
let dir = TempDir::new("lin-pat");
let path = dir.file("mine.lin", LIN);
let mut s = Session::new();
s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap();
assert!(linetype_pattern(&s, "MYTEXT").is_some());
@@ -58,7 +55,8 @@ fn linetype_json_reads_a_lin_file_by_path() {
#[test]
fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {
let path = file("cmdline.lin", LIN);
let dir = TempDir::new("lin-pat");
let path = dir.file("cmdline.lin", LIN);
let mut s = Session::new();
s.cmdline("-linetype").unwrap();
for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] {
@@ -72,7 +70,7 @@ fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {
assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log);
// Already loaded: asked whether to reload, in the file's order.
let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2"));
let changed = dir.file("changed.lin", LIN.replace(".5,-.25", "2,-2"));
s.cmdline("-linetype").unwrap();
for line in ["l", "*", changed.as_str()] {
s.cmdline(line).unwrap();
@@ -132,12 +130,16 @@ fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() {
#[test]
fn hatch_pattern_prompt_takes_a_pat_file_name() {
let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");
let dir = TempDir::new("lin-pat");
let path = dir.file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.cmdline("hatch").unwrap();
s.cmdline("p").unwrap();
s.cmdline(&path).unwrap();
// Its scale and angle follow; Enter keeps them.
s.cmdline("").unwrap();
s.cmdline("").unwrap();
// The pattern named like the file is the one used.
s.cmdline("5,5").unwrap();
s.cmdline("").unwrap();
@@ -149,9 +151,21 @@ fn hatch_pattern_prompt_takes_a_pat_file_name() {
s.cmdline("hatch").unwrap();
s.cmdline("p").unwrap();
s.cmdline("myother").unwrap();
s.cmdline("").unwrap();
s.cmdline("").unwrap();
s.cmdline("5,5").unwrap();
s.cmdline("").unwrap();
assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1);
// A file with no pattern in it, or none at all, asks for the pattern again.
let empty = dir.file("EMPTY.pat", ";; no patterns\n");
for file in [empty, dir.path_str("MISSING.pat")] {
s.cmdline("hatch").unwrap();
s.cmdline("p").unwrap();
s.cmdline(&file).unwrap();
assert!(s.prompt_text().contains("Enter a pattern name"), "{file}: {}", s.prompt_text());
s.cancel();
}
}
#[test]
+126
View File
@@ -0,0 +1,126 @@
//! Fuzz every registered command with adversarial params: a command must return `Err`, never panic
//! or hang. Each call gets a fresh, populated session on its own thread with a deadline, so one bad
//! command can't change what the next one sees or wedge the run.
//!
//! `Session::execute` contains a command's panic and returns an ordinary `Err`, so looking at the
//! result can't find one, and neither can `catch_unwind` here. A panic hook counts them instead,
//! on whichever thread they happen. `contained_panics_count_and_graceful_errors_do_not` proves it.
use cadcraft_engine::{Session, command_specs};
use serde_json::{Value, json};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, Once, PoisonError, mpsc};
use std::time::Duration;
/// A drawing with something to act on, so commands reach their real work: with nothing in it most
/// of them stop at "nothing to do", and with no drawing at all they don't run.
fn fresh() -> Session {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0], [10, 10]]})).unwrap();
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
s
}
/// Wall-clock budget for one command on the small drawing. Real work takes milliseconds, so this
/// only catches hangs and work that grows with a number in the params.
const DEADLINE: Duration = Duration::from_secs(4);
/// Budget for the self-test, which checks how panics are counted: the hook prints a backtrace
/// under `RUST_BACKTRACE=1`, and the first symbol lookup can be slow on a loaded runner.
const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120);
/// Panics anywhere in the process, counted by a hook.
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// One run at a time, so a panic is charged to the run that caused it.
static RUN: Mutex<()> = Mutex::new(());
/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it finished
/// without panicking, contained or not (returning `Ok` or a graceful `Err` both count).
fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option<bool> {
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
PANICS.fetch_add(1, Ordering::SeqCst);
default(info);
}));
});
let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner);
let before = PANICS.load(Ordering::SeqCst);
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let r = catch_unwind(AssertUnwindSafe(f));
let _ = tx.send(r.is_ok());
});
rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before)
}
fn run_once(id: &str, p: &Value) -> Option<bool> {
let (id, p) = (id.to_string(), p.clone());
run_watched(DEADLINE, move || {
let mut s = fresh();
let _ = s.execute(&id, &p);
})
}
/// `Err` with a reason on a panic or a hang. A real hang reproduces and one-time setup (the first
/// font scan) doesn't, so a timeout counts only if a rerun on a fresh session times out too.
fn run_guarded(id: &str, p: &Value) -> Result<(), String> {
match run_once(id, p).or_else(|| run_once(id, p)) {
Some(true) => Ok(()),
Some(false) => Err("panicked".into()),
None => Err(format!("hung (>{}s, twice)", DEADLINE.as_secs())),
}
}
/// Shapes and sizes a hostile script, drawing or agent could send. No param names a real path:
/// the hunt must not write files.
fn adversarial() -> Vec<Value> {
let long = "x".repeat(100_000);
vec![
json!(null),
json!({}),
json!([]),
json!("x"),
json!({"points": [[1e308, -1e308], ["a"]], "handles": ["zz", 99999]}),
json!({"points": [], "handles": [], "center": [], "from": [], "to": [], "name": "", "layer": "", "style": "", "text": ""}),
json!({"center": [1e308, 1e308], "radius": 1e308, "angle": 1e308, "scale": 1e308, "height": 1e308, "width": 1e308, "distance": 1e308}),
json!({"center": [0, 0], "radius": -1, "angle": -1e308, "scale": 0, "height": 0, "width": 0, "distance": -1, "factor": 0}),
json!({"count": 1e9, "rows": 1e9, "columns": 1e9, "levels": 1e9, "items": 1e9, "sides": 1e9, "segments": 1e9, "index": 1e9}),
json!({"count": -1, "rows": -1, "columns": -1, "levels": -1, "items": -1, "sides": -1, "segments": -1, "index": -1}),
json!({"name": long, "layer": long, "text": long, "style": long, "mode": long}),
json!({"name": "\u{0}\u{202e}\u{1f4a5}", "text": r"%%%\P{\H0;", "gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
]
}
#[test]
fn no_command_panics_or_hangs_on_adversarial_params() {
let params = adversarial();
let mut bad: Vec<String> = Vec::new();
for spec in command_specs() {
for p in &params {
if let Err(why) = run_guarded(spec.id, p) {
let shown: String = p.to_string().chars().take(120).collect();
bad.push(format!("{} [{why}] <- {shown}", spec.id));
break;
}
}
}
assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n"));
}
/// A panic the code under test contains still fails the run; a graceful `Err` does not. Without
/// this the hunt could quietly stop detecting anything.
#[test]
fn contained_panics_count_and_graceful_errors_do_not() {
let contained = run_watched(SELF_TEST_DEADLINE, || {
let _ = catch_unwind(|| panic!("contained, as Session::execute does"));
});
assert_eq!(contained, Some(false));
let graceful = run_watched(SELF_TEST_DEADLINE, || {
let mut s = fresh();
assert!(s.execute("no-such-command", &json!({})).is_err());
});
assert_eq!(graceful, Some(true));
}
+296
View File
@@ -0,0 +1,296 @@
//! Saving over a drawing keeps the previous file as `<name>.bak` (ISAVEBAK, #540), and the
//! save itself replaces the file atomically: no temporary files are left behind.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, backup_path, looks_complete, looks_like_drawing, save_keeps_current, set_io};
use serde_json::json;
fn hooks() {
set_io(IoHooks {
read: |b, n| cadcraft_io::read(b, n).map_err(|e| e.to_string()),
write: |d, n| cadcraft_io::write(d, n).map_err(|e| e.to_string()),
plot: None,
});
// Saves at a chosen version (a DWG 2000), as the app's.
cadcraft_engine::cmd::file::set_write_as(|d, n, o| {
cadcraft_io::write_with(d, n, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("cadcraft-bak-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn circles(path: &std::path::Path) -> usize {
let d = cadcraft_io::read(&std::fs::read(path).unwrap(), &*path.to_string_lossy()).unwrap();
d.model.iter().filter(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Circle(_))).count()
}
/// Only the drawing and its backup are in the folder: atomic saves leave no staging files.
fn files(dir: &std::path::Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
v.sort();
v
}
/// ISAVEBAK is process-wide: the tests that save hold this lock, so one turning it off can't
/// race another's save. Dropping the guard turns it back on, also when a test fails.
static SAVES: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct Saving(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
impl Drop for Saving {
fn drop(&mut self) {
cadcraft_engine::autosave::set_isavebak(true);
}
}
fn saving() -> Saving {
let g = SAVES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
cadcraft_engine::autosave::set_isavebak(true);
Saving(g)
}
fn add_circle(s: &mut Session, x: f64) {
s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap();
}
#[test]
fn each_save_keeps_the_previous_file_as_bak() {
let _saving = saving();
hooks();
let dir = temp_dir("rotate");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
assert_eq!(files(&dir), ["Plan.dxf"], "a first save has nothing to back up");
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save");
add_circle(&mut s, 10.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() {
let _saving = saving();
hooks();
let dir = temp_dir("damaged");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(circles(&backup_path(&path)), 1);
// Another program truncates the drawing; the next save keeps the good backup.
std::fs::write(&path, b"").unwrap();
add_circle(&mut s, 10.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1));
// Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup
// stays too.
let whole = std::fs::read(&path).unwrap();
std::fs::write(&path, &whole[..whole.len() / 2]).unwrap();
add_circle(&mut s, 11.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(circles(&backup_path(&path)), 1, "half a DXF");
std::fs::write(&path, b"AC1032").unwrap();
add_circle(&mut s, 12.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone");
// ISAVEBAK 0: the backup is left as it was.
s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap();
add_circle(&mut s, 15.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1));
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn drawings_are_recognised_by_their_start() {
assert!(looks_like_drawing(b"AC1032\0\0\0\0\0"));
assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"));
assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n"));
assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0"));
for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"<svg>", b"hello"] {
assert!(!looks_like_drawing(junk), "{junk:?}");
}
}
/// A private drawing (0600) keeps a private backup: a new .bak, and a .bak already there that
/// others could read, both take the drawing's mode.
#[cfg(unix)]
#[test]
fn the_backup_is_no_more_readable_than_the_drawing() {
use std::os::unix::fs::PermissionsExt;
let _saving = saving();
hooks();
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let dir = temp_dir("mode");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(mode(&backup_path(&path)), 0o600, "a new .bak");
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o644)).unwrap();
add_circle(&mut s, 10.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(mode(&backup_path(&path)), 0o600, "a .bak already there");
assert_eq!(circles(&backup_path(&path)), 2);
// A .bak kept more private than the drawing is never widened.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap();
add_circle(&mut s, 15.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(mode(&backup_path(&path)), 0o600, "a more private .bak");
let _ = std::fs::remove_dir_all(&dir);
}
/// A symlink planted at the .bak is replaced by the backup, never written through: the file it
/// pointed at is left as it was.
#[cfg(unix)]
#[test]
fn a_symlink_at_the_backup_is_not_followed() {
let _saving = saving();
hooks();
let dir = temp_dir("link");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let other = dir.join("other.txt");
std::fs::write(&other, b"someone else's file").unwrap();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
std::os::unix::fs::symlink(&other, backup_path(&path)).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read(&other).unwrap(), b"someone else's file");
assert!(std::fs::symlink_metadata(backup_path(&path)).unwrap().is_file(), "the .bak is a file now");
assert_eq!(circles(&backup_path(&path)), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// A drawing looks whole from its end: an ASCII DXF ends with 0/EOF, a binary one with EOF and
/// its NUL, and a DWG is not a few bytes long.
#[test]
fn whole_drawings_are_told_from_cut_off_ones() {
let ascii = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert!(looks_complete(ascii, ascii, ascii.len() as u64));
let cut = &ascii[..30];
assert!(!looks_complete(cut, cut, cut.len() as u64));
let mut binary = b"AutoCAD Binary DXF\r\n\x1a\0".to_vec();
binary.extend_from_slice(b"\0\0SECTION\0\0\0EOF\0");
assert!(looks_complete(&binary, &binary, binary.len() as u64));
assert!(!looks_complete(&binary, &binary[..binary.len() - 5], binary.len() as u64 - 5));
assert!(!looks_complete(b"AC1032", b"AC1032", 6));
assert!(!looks_complete(b"AC1032", b"", 30_000), "a DWG tag without its header is not whole");
}
/// A DWG reaches the end its own header gives: cut off anywhere (not only right after its start),
/// in a 2018 and a 2000 file, it doesn't replace the good backup; whole, it does.
#[test]
fn a_dwg_cut_off_anywhere_keeps_the_good_backup() {
let _saving = saving();
hooks();
for version in [2018, 2000] {
let dir = temp_dir(&format!("dwg{version}"));
let path = dir.join("Plan.dwg");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p, "version": version })).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the first save is backed up");
let whole = std::fs::read(&path).unwrap();
let head = &whole[..whole.len().min(4096)];
assert!(looks_complete(head, &whole[whole.len().saturating_sub(256)..], whole.len() as u64), "{version}: whole");
for keep in [whole.len() / 2, whole.len() * 9 / 10, whole.len() - 1] {
assert!(!looks_complete(head, &[], keep as u64), "{version}: cut to {keep} of {}", whole.len());
}
// Another program leaves it cut off at 90%: the next save keeps the good backup.
std::fs::write(&path, &whole[..whole.len() * 9 / 10]).unwrap();
add_circle(&mut s, 10.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the cut-off file didn't replace it");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// The checks can be wrong about a real file. With no backup yet, a file that doesn't look whole
/// is still kept, because the save is about to replace the only copy of it.
#[test]
fn a_file_that_looks_damaged_is_kept_when_there_is_no_backup_yet() {
let _saving = saving();
hooks();
let dir = temp_dir("first");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
let odd = b"AC1032 but not as this header expects it".to_vec();
std::fs::write(&path, &odd).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), odd);
let _ = std::fs::remove_dir_all(&dir);
}
/// What the warning promises and what the save does agree. A 2007 DWG, whose completeness can't
/// be told, replaces an older backup; a file known to be cut off doesn't.
#[test]
fn the_backup_a_save_promises_is_the_one_it_writes() {
let _saving = saving();
hooks();
let dir = temp_dir("promise");
let path = dir.join("Plan.dxf");
let p = path.to_string_lossy().to_string();
let mut s = Session::new();
add_circle(&mut s, 0.0);
s.execute("saveas", &json!({ "path": p })).unwrap();
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
let old = std::fs::read(backup_path(&path)).unwrap();
let mut dwg2007 = b"AC1021".to_vec();
dwg2007.resize(4096, 7);
std::fs::write(&path, &dwg2007).unwrap();
assert!(save_keeps_current(&path));
add_circle(&mut s, 10.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), dwg2007, "the promise was kept");
std::fs::write(backup_path(&path), &old).unwrap();
std::fs::write(&path, b"AC1032").unwrap();
assert!(!save_keeps_current(&path));
add_circle(&mut s, 15.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), old, "cut off: nothing was promised");
cadcraft_engine::autosave::set_isavebak(false);
assert!(!save_keeps_current(&path), "backups off");
let _ = std::fs::remove_dir_all(&dir);
}
+46
View File
@@ -0,0 +1,46 @@
//! A test's own folder in the system temp directory, removed when the test ends, even when an
//! assertion fails. Shared by the integration tests (`#[path]`) and the crate's unit tests.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// `cadcraft-<tag>-<pid>-<n>`: the process id keeps parallel test binaries apart and the counter
/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder.
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the test's temp folder");
Self(dir)
}
/// Write `contents` to `name` in the folder; returns the file's path as text.
pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String {
let p = self.0.join(name);
std::fs::write(&p, contents).expect("write a test file");
p.to_string_lossy().into_owned()
}
/// The path of `name` in the folder as text, without creating it.
pub fn path_str(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl std::ops::Deref for TempDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
+20 -17
View File
@@ -9,6 +9,11 @@ use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::Drawing;
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
static FILES: Mutex<Vec<(String, Drawing)>> = Mutex::new(Vec::new());
fn write(d: &Drawing, name: &str) -> Result<Vec<u8>, String> {
@@ -27,17 +32,11 @@ fn hooks() {
set_io(IoHooks { read, write, plot: None });
}
fn path(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
/// A drawing with an office layer and a title block circle, saved as a template.
fn office_template(s: &mut Session, name: &str) -> String {
fn office_template(s: &mut Session, dir: &TempDir, name: &str) -> String {
s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap();
s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap();
let p = path(name);
let p = dir.path_str(name);
s.execute("saveas", &json!({ "path": p })).unwrap();
p
}
@@ -46,7 +45,8 @@ fn office_template(s: &mut Session, name: &str) -> String {
fn saveas_dwt_then_new_from_the_template() {
hooks();
let mut s = Session::new();
let dwt = office_template(&mut s, "office.dwt");
let dir = TempDir::new("templates");
let dwt = office_template(&mut s, &dir, "office.dwt");
// A template is saved like a drawing: the format sees the .dwt name, the drawing is saved.
assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt")));
let st = s.state().unwrap();
@@ -68,7 +68,8 @@ fn saveas_dwt_then_new_from_the_template() {
fn qnewtemplate_is_the_default_for_new_and_qnew() {
hooks();
let mut s = Session::new();
let dwt = office_template(&mut s, "default.dwt");
let dir = TempDir::new("templates");
let dwt = office_template(&mut s, &dir, "default.dwt");
let docs = s.docs.len();
s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap();
@@ -99,8 +100,8 @@ fn a_template_that_cannot_be_read_makes_no_drawing() {
let docs = s.docs.len();
let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string();
assert!(err.contains("office.dwt"), "{err}");
let junk = path("junk.dwt");
std::fs::write(&junk, b"not a drawing at all").unwrap();
let dir = TempDir::new("templates");
let junk = dir.file("junk.dwt", b"not a drawing at all");
assert!(s.execute("new", &json!({ "template": junk })).is_err());
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
assert_eq!(s.docs.len(), docs);
@@ -121,6 +122,7 @@ fn layout_names(d: &Drawing) -> Vec<String> {
fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
hooks();
let mut s = Session::new();
let dir = TempDir::new("templates");
for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] {
s.execute("layer.new", &json!({ "name": name })).unwrap();
}
@@ -138,7 +140,7 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count();
let p = path("sheet.dwt");
let p = dir.path_str("sheet.dwt");
let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap();
assert_eq!(r["name"], json!("Sheet"), "the current layout by default");
assert_eq!(r["path"], json!(p));
@@ -175,13 +177,14 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
hooks();
let mut s = Session::new();
let dir = TempDir::new("templates");
s.cmdline("LAYOUT").unwrap();
s.cmdline("sa").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template <Layout1>:");
s.cmdline("Layout2").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name <Layout2.dwt>:");
// No extension: the template is a .dwt.
let p = path("typed");
let p = dir.path_str("typed");
s.cmdline(&p).unwrap();
assert!(s.current_prompt().is_none());
let saved = format!("{p}.dwt");
@@ -189,11 +192,11 @@ fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]);
// The JSON form of LAYOUT, other file types, a missing path and an unknown layout.
let dxf = path("sheet1.dxf");
let dxf = dir.path_str("sheet1.dxf");
let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap();
assert_eq!(r["name"], json!("Layout1"));
assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]);
assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err());
assert!(s.execute("layout.saveas", &json!({ "path": dir.path_str("sheet.png") })).is_err());
assert!(s.execute("layout.saveas", &json!({})).is_err());
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err());
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": dir.path_str("nope.dwt") })).is_err());
}
+9 -7
View File
@@ -2,7 +2,7 @@
//! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/
//! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references.
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use cadcraft_engine::Session;
@@ -12,6 +12,11 @@ use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, MAX_BLOCK_DEPTH, XrefStatus};
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
@@ -20,12 +25,9 @@ fn hooks() {
});
}
/// A fresh folder for one test.
fn folder(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
/// A fresh folder for one test, removed when the test ends.
fn folder(tag: &str) -> TempDir {
TempDir::new(&format!("xref-{tag}"))
}
fn save(d: &Drawing, path: &Path) {
+130 -30
View File
@@ -502,6 +502,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 {
origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot)
}
/// How far below the baseline a bottom-justified text's alignment point lies: a third of the
/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an
/// outline font (#2229).
fn bottom_depth(font: &TextFont, height: f64) -> f64 {
match font {
TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height),
TextFont::Stroke => height / 3.0,
}
}
/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline;
/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the
/// string's own extents, descenders included, not at half the height (#2229).
fn ink_middle(sh: &Shaped) -> Option<f64> {
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite());
let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)));
(lo <= hi).then_some((lo + hi) / 2.0)
}
/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`.
fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 {
let dx = match h {
Align::Left | Align::Aligned | Align::Fit => 0.0,
Align::Center | Align::Middle => -sh.width / 2.0,
Align::Right => -sh.width,
};
let dy = match (h, v) {
(Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0),
(_, VAlign::Baseline) => 0.0,
(_, VAlign::Bottom) => bottom_depth(font, height),
(_, VAlign::Middle) => -height / 2.0,
(_, VAlign::Top) => -height,
};
Vec2::new(dx, dy)
}
/// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline
/// start from that point (along the text), its rotation and its mirroring (backwards, upside
/// down). Aligned and fit text are placed by their insertion point.
@@ -512,25 +548,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) {
let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2));
return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0));
}
let width = shape_line(font, s, height, p.width_factor, p.oblique).width;
let sh = shape_line(font, s, height, p.width_factor, p.oblique);
let origin = match (p.h, p.v) {
(Align::Left, VAlign::Baseline) => p.insert,
_ => p.align_pt.unwrap_or(p.insert),
};
let dx = match p.h {
Align::Left | Align::Aligned | Align::Fit => 0.0,
Align::Center | Align::Middle => -width / 2.0,
Align::Right => -width,
};
let dy = match (p.h, p.v) {
(Align::Middle, _) => -height / 2.0,
(_, VAlign::Baseline) => 0.0,
(_, VAlign::Bottom) => height / 3.0,
(_, VAlign::Middle) => -height / 2.0,
(_, VAlign::Top) => -height,
};
let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
(origin, Vec2::new(dx, dy), rot, mirror)
(origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror)
}
/// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its
@@ -563,28 +587,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) {
(Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert,
_ => p.align_pt.unwrap_or(insert),
};
let dx = match p.h {
Align::Left | Align::Aligned | Align::Fit => 0.0,
Align::Center | Align::Middle => -width / 2.0,
Align::Right => -width,
};
let dy = match (p.h, p.v) {
(Align::Middle, _) => -height / 2.0,
(_, VAlign::Baseline) => 0.0,
(_, VAlign::Bottom) => height / 3.0,
(_, VAlign::Middle) => -height / 2.0,
(_, VAlign::Top) => -height,
};
let local = Vec2::new(dx, dy);
let local = justify_offset(font, &sh, height, p.h, p.v);
let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
let xf = |q: Vec2| {
let l = q + local;
origin + Vec2::new(l.x * mx, l.y * my).rotate(rot)
};
sh.map(xf);
let bb = Bounds2::from_points(
[Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf),
);
let depth = bottom_depth(font, height);
let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf));
(sh, bb)
}
@@ -797,3 +808,92 @@ mod ttf_tests {
assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A'));
}
}
#[cfg(test)]
mod justify_tests {
use super::*;
const H: f64 = 2.5;
/// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`.
fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) {
let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) };
(place(font, s, &p).0, baseline_start(font, s, &p))
}
/// The lowest and highest point of placed text's ink.
fn ink_y(sh: &Shaped) -> (f64, f64) {
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y);
ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)))
}
/// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it,
/// not at half the height: lowercase and descenders move the baseline (#2229).
#[test]
fn a_middle_text_is_centred_on_its_ink() {
let font = TextFont::Stroke;
let mut baselines = Vec::new();
for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] {
let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline);
let (lo, hi) = ink_y(&sh);
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point");
// The insertion point a DWG keeps (group 10) is the baseline start, below the ink's
// middle by as much as the unplaced text's ink middle is above its baseline.
let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0));
assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}");
baselines.push((s, start.y));
}
// Capitals and digits span the height, so they stay at half of it.
assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}");
// Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher.
for (s, y) in &baselines[1..] {
if *s != "Tg 1234 ABC" {
assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}");
}
}
}
/// Text that draws nothing has no ink to centre: it keeps half the height.
#[test]
fn a_middle_text_without_ink_keeps_half_the_height() {
let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline);
assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}");
}
/// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is
/// a third of the height above its alignment point, and Middle Center still uses half.
#[test]
fn stroke_font_bottom_and_middle_center_are_unchanged() {
let font = TextFont::Stroke;
for h in [Align::Left, Align::Center, Align::Right] {
let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom);
assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}");
}
let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle);
assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}");
}
/// With a TrueType font, a bottom-justified text's alignment point is at the face's descent,
/// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height).
/// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have
/// one); skips without any.
#[test]
fn a_bottom_justified_truetype_text_sits_on_its_descent() {
let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f)));
let Some((name, face)) = found else { return };
let ratio = ttf::descent_ratio(&face).unwrap();
let font = TextFont::Outline(face);
let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom);
assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}");
if name == "Arial" {
assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}");
// The descenders reach the alignment point's line: h/3 left them 0.1 above it.
let (lo, _) = ink_y(&sh);
assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point");
}
// And Middle centres the ink, as with the stroke font.
let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline);
let (lo, hi) = ink_y(&sh);
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}");
}
}
+10
View File
@@ -709,6 +709,16 @@ impl OutlinePen for Pen {
}
}
/// How far below the baseline a face reaches, as a fraction of the text height (which is the
/// face's cap height): its descent over its cap height. A bottom-justified text's alignment
/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent.
pub fn descent_ratio(face: &Face) -> Option<f64> {
let f = face.font()?;
let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent);
let r = descent / cap_height(&f);
(r.is_finite() && r > 0.0 && r < 2.0).then_some(r)
}
/// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height
/// of its "H", else an estimate from the ascent.
fn cap_height(f: &FontRef) -> f64 {

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