Compare commits

...
Author SHA1 Message Date
ZaxaerithandZaxaerith 7cee28580d fix(dxf): preserve 3D LEADER vertex elevations (#2245) (#2397)
Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
2026-10-31 00:34:27 -07:00
a83af92eb2 wblock: write the source drawing's $INSUNITS and $MEASUREMENT (#2002) (#2023)
* wblock: write the source drawing's $INSUNITS and $MEASUREMENT

run_wblock built the written drawing from Drawing::new_imperial() and
never copied the source's units, so every WBLOCK file declared inches
($INSUNITS 1) and imperial measurement ($MEASUREMENT 0) whatever the
source: a block written from a metric drawing came back at 0.0254 of
its size in any program that honours $INSUNITS on insert. The written
drawing now takes the source's values for both variables, as AutoCAD's
-WBLOCK does. A unitless source ($INSUNITS 0, kept by the reader per
#1209) writes unitless.

Co-Authored-By: GLM-5.3 <noreply@z.ai>

* wblock: start from the source drawing's header and styles

Clone the source header (resetting $INSBASE as before) and copy the
multileader, table and multiline styles and the annotation scale list
along with the other tables, so a block written from a metric drawing
keeps its text size, current dimension style, limits and scales, not
the imperial template's (review on #2023).

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

* wblock_units: each WBLOCK call writes into its own TempDir

The two tests shared one temp path and raced when cargo ran them in
parallel (review on #2023).

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

---------

Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:34:23 -07:00
4552b35688 fix(ui): dash only the visible part of a highlight, so zooming far in doesn't freeze (#2299) (#2454)
* fix(ui): dash only the visible part of a highlight, so zooming far in doesn't freeze (#2299)

With the pointer on an object, its rollover highlight is dashed, and draw_highlight dashed the
object's whole outline in screen space, off-screen parts included: one shape per 10 points of
it. Zoomed far in, a short line is billions of points long, so the app froze and then ran out
of memory, which is the freeze and crash #2299 reports. Wheel-zooming a circle and two lines
with the pointer on them reached 7.9 million shapes in one frame at a view height of 0.001
before a 1 GB allocation failed.

The highlight now clips each outline to the view, plus a margin, in world coordinates first
(precise when zoomed far in), and dashes only what is left. Each visible piece starts its dash
pattern at its distance along the whole outline, so dashes don't crawl along a line that runs
off screen while the view pans. The two dashed rectangles drawn in screen space, a layout's
printable area and the crossing window, clip each edge to the painter's clip rect for the same
reason. The same zoom now stays under 1,300 shapes per frame down to the zoom limit.

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

* fix(ui): a clipped rectangle edge keeps its dashes where the whole edge puts them

dashed_rect clipped each edge to the screen and then started its dashes at the clip boundary,
so a printable area or crossing window running off screen had its dashes move with the view,
and its bottom and left edges were drawn in reverse. Each edge now keeps its direction and its
dashes start at their distance along the whole edge, as the highlight's outlines do. The
zoomed-far-in app test also checks that the line really is hovered, so it can't pass with no
highlight drawn.

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:34:18 -07:00
Tanish Mankuandoctopoulpos e60d546ce4 Picking after an edit no longer ends the app on an rstar panic (#2539)
rstar 0.13 can panic when inserting after removals in a bulk-loaded tree, and picking runs outside the command guard. The patch now runs on a copy under catch_unwind and falls back to a bulk load. The web build can't catch a panic, so it always bulk-loads. Diagnosis, reproduction and fix from #2468.

Fixes #2468

Co-authored-by: octopoulpos <32617732+octopoulpos@users.noreply.github.com>
2026-10-31 00:34:14 -07:00
Tanish MankuandFilippo Reimer 42be8540a9 Backups of drawings sharing a name no longer overwrite each other (#2537)
plan.dwg and plan.dxf in one folder shared plan.bak. A DWG keeps <name>.bak, as AutoCAD names it, and any other drawing appends .bak. The naming comes from #1006.

Co-authored-by: Filippo Reimer <49290121+filforce95@users.noreply.github.com>
2026-10-31 00:26:50 -07:00
Dylan M. Taylor a1335895fc Merge pull request #2527 from aiongg/docs-tidy
docs: tidy agent, contributor and asset docs
2026-10-31 01:06:48 -04:00
Dylan M. Taylor b6c7a73fa6 Merge pull request #2530 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:52 -04:00
b35f00847a Validate new linetype names in the rename command (#2519)
* Validate new linetype names in the rename command

Found while testing crates/engine/src/cmd/props.rs:1154-1200 (run_rename, "linetype" branch)

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

* rename: a linetype's new name is stored trimmed, as -RENAME types it (CodeRabbit)

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 00:45:52 -04:00
Brandon ThomasandClaude Opus 5.5 c6e49b177c test(autosave): fix a flake: wait for the background write to finish, not just for its file
Since #2518 the write syncs the folder after the rename, so the autosave
file exists while the write is still running. Until it finishes the drawing
is busy and the next change isn't planned, so the test's tick() returned
None. Autosaver::writing() says whether a write is still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:44:27 -04:00
aiongg 11ce7eb52c updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:07 +00:00
aiongg 9b32882065 docs: tidy wording 2026-10-31 04:25:44 +00:00
aiongg ee715f4636 docs: tidy agent, contributor and asset docs 2026-10-31 04:17:25 +00:00
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
Brandon ThomasandClaude Opus 5.5 fbd97d432c Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:15 -04:00
Brandon ThomasandClaude Opus 5.5 d9265c9245 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:14 -04:00
aiongg 5654240729 refactor(cli): parse cadcraft-cli arguments with clap, following RFC-0012 (#2268)
* test(cli): pin today's command-line behaviour

The later commits change these tests on purpose, so each behaviour change shows in their diff.

* refactor(cli): split main.rs into modules, move only

- output: outln!, stdout state, atomic write, export helpers, view/window helpers
- args: USAGE, option validation, framing options, MCP argument parsing and its tests
- info, convert, sample, mcp: one subcommand each
- perf: synthetic drawing, Rng, perf subcommands
- run: run subcommand and {last} handle chaining
- main: crate docs, mod lines, install_io, open, main, cli, unit tests

No behaviour change.

* refactor(cli): typed errors and run(args, out, err)

cadcraft-cli is now a library plus a thin binary. `cadcraft_cli::run(args, out, err)
-> i32` is the whole program; main.rs only passes `args_os()` and the process
streams. Handlers write through a `Streams` wrapper (output.rs) instead of the
process streams; it keeps the closed-stdout semantics (drop later output, carry
on, a closed pipe is not an error, other write errors are reported once and make
the exit code 1). `mcp` still speaks MCP on the real stdin/stdout.

Errors are typed (thiserror): CliError (error.rs) wraps EngineError, DocError,
IoError, io::Error and the per-module WindowError, ArgsError, RunError,
OutputError and PerfError. `run` prints `error: {e}` and exits 1.

The usage text and the "unknown option/argument" messages stay as one
transitional CliError::Usage(String), printed without the `error: ` prefix.
USAGE stays a const; step 4 (clap) removes both. The engine's IoHooks take
`Result<_, String>`, so the three `.map_err(|e| e.to_string())` in install_io
remain. The hooks are set-once (OnceLock), so installing them on every `run`
call is harmless.

tests/it/usage.rs calls `run` in-process with Vec<u8> writers; the headless mcp
case runs through the binary (it reads the real stdin) and a non-Unicode
argument test is new.

Behaviour changes (old -> new):
- Every error line other than the usage-class ones gains the prefix `error: `
  (e.g. `--view needs ...` -> `error: --view needs ...`, `unknown command
  `x`` -> `error: unknown command `x``, `invalid parameters for `open`: ...`
  -> `error: invalid parameters ...`, `unsupported file type` likewise).
- `cadcraft-cli: cannot connect to HOST:PORT: e` -> `error: cannot connect to
  HOST:PORT: e` (the old prefix is replaced).
- A non-Unicode argument: panic, exit 101 -> `error: argument "..." is not
  valid Unicode`, exit 1.
- Unchanged: usage text, `X: unknown option --y`, `unknown option --y`,
  `mcp: unknown argument ...` (no prefix); warnings and `wrote ...` lines;
  the `cadcraft-cli: can't write to stdout: e` report; exit codes.

* refactor(cli): parse arguments with clap

Replace the hand-written parser (USAGE, validate_options, OptionSpec,
framing_option, parse_mcp_args, CliError::Usage) with clap derive types
in args.rs: a Cli parser and one Args struct per subcommand, with the
framing flags (--window, --view) in a FramingArgs struct flattened into
convert and run. USAGE's framing and --cmd chaining text moves into the
subcommands' and flags' doc comments and run's long help, so --help
carries it. `commands` moves from an inline closure to commands.rs.
`command()` exposes the clap definition for man pages and completions.

clap reads the arguments as OsStrings. Help and --version go to stdout
with exit 0; usage errors are clap's, on stderr with exit 2. A bare
--help or -h before `--` shows the subcommand's help even after a
mistake on the line (`run --script --help`). run opens its drawings
(FILEs, --sample, --metric) and runs its steps (--script, --script-file,
--cmd) in command-line order, from ArgMatches::indices_of. Values stay
strings checked by the handlers, with their messages and exit 1
(--window, --view, --cmd JSON, sample names, perf counts). The last of a
repeated flag wins (args_override_self on every subcommand but mcp).

Behaviour changes:

Help and version
- --help, -h, help: the USAGE page -> clap's help, first line
  "cadcraft-cli: headless CADCraft".
- bare cadcraft-cli: USAGE on stderr, exit 1 -> clap's help on stderr,
  exit 2.
- <subcommand> --help / -h, help <subcommand>: "unknown option --help"
  (exit 1), the USAGE page (convert, help <subcommand>) or a file named
  -h (info -h, run -h) -> that subcommand's help, stdout, exit 0.
- <subcommand> --version: "unknown option --version", exit 1 -> clap's
  unexpected argument, exit 2.

Usage errors (exit 1 -> exit 2, clap's messages)
- Unknown subcommand, empty subcommand, unknown top-level flag: USAGE ->
  "error: unrecognized subcommand 'foo'" / "error: unexpected argument
  '--bogus' found".
- Unknown flag on any subcommand: "info: unknown option --bogus",
  "unknown option --bogus", "mcp: unknown argument --bogus" or USAGE
  (convert) -> "error: unexpected argument '--bogus' found".
- A short flag where a file is expected (info -x): opened as a file ->
  unexpected argument; `--` before it keeps it a file name.
- A value flag at the end or followed by a flag (--window, --view,
  --script, --script-file, --cmd, --save, --export, --connect): "error:
  --script needs text" and the like, or the next flag taken as the
  value (--window --view, --script --sample, --cmd --sample,
  --script-file --sample) -> "error: a value is required for '--script
  <TEXT>' but none was supplied". --connect followed by an unknown flag
  (--connect --foo, --connect -v) names that flag instead (clap reports
  the unknown argument, not the missing value).
- mcp --connect with an empty value: "error: --connect needs HOST:PORT
  ..." -> "error: invalid value ' ' for '--connect <HOST:PORT>':
  expected HOST:PORT (...)"; --connect=-v likewise.
- mcp --connect twice: "error: --connect given more than once" ->
  "error: the argument '--connect <HOST:PORT>' cannot be used multiple
  times".
- mcp --lazy / --launch without --connect: "error: --lazy and --launch
  need --connect HOST:PORT" -> "error: the following required arguments
  were not provided: --connect <HOST:PORT>".
- Missing positionals (info, convert with 0 or 1 files, sample with 0
  or 1 arguments): USAGE -> "error: the following required arguments
  were not provided".
- Extra positionals: convert with three files (USAGE), and info,
  commands, perf, sample, which ignored the extra argument -> "error:
  unexpected argument 'extra' found".
- perf -5: opened as a file -> unexpected argument '-5'.
- A non-Unicode argument where text is expected: "error: argument ... is
  not valid Unicode", exit 1 -> "error: invalid UTF-8 was detected in
  one or more arguments", exit 2.

Accepted where they were errors
- --x=v is a spelling of --x v for every value flag (--window, --view,
  --script, --script-file, --cmd, --save, --export, --connect); before
  it was an unknown option or USAGE. A value starting with - needs the
  = form (--cmd=-x).
- commands --: "unknown option --" -> `--` ends the options (all
  commands listed).
- run --view extents --view current: "use only one of --window and
  --view" -> the last wins, like every other repeat.

Other
- run --sample --sample opens one sample drawing (was two): repeated
  flags are last-wins.
- run checks --window/--view values before opening any drawing, so a
  bad framing value is reported ahead of a missing file.
- `-h` after a mistake shows the long help (`--help`): the retry asks
  for --help.

Unchanged: --window with --view, framing without an image output,
--view's case-insensitivity, all value messages (--window numbers,
--view values, --cmd JSON, {last}, sample names, --script-file read
errors), multiple FILEs, --sample with --metric or a FILE, last-wins
for --save, --export, --window, --script/--cmd order, and `\n` in
--script.

clap forced: exit 2 for usage errors, its message wording, and the
unknown-flag-over-missing-value precedence above.

* refactor(cli): reject repeated flags, long version (photocraft#7071 decisions)

Behaviour changes, old -> new:
- --save, --export, --window, --view, --sample, --metric, --lazy, --launch given twice:
  last one won -> usage error, exit 2 ("the argument ... cannot be used multiple times").
  --connect was already rejected. --script, --script-file, --cmd and run's FILEs still repeat.
- --version / -V: "cadcraft-cli <version>" -> "cadcraft-cli <version> (<sha>)", sha from
  CADCRAFT_BUILD_SHA or "dev", as the desktop app prints it.
- "cadcraft-cli version" is an unrecognized-subcommand usage error (as before; now tested).
- A value starting with "-" needs the = form for every value flag (as before, tested).
  --window -10,-10,10,10 stays a usage error ("unexpected argument -1"); write
  --window=-10,-10,10,10. clap's allow_negative_numbers does not accept a comma list, and
  allow_hyphen_values would let --window swallow the next flag, so neither is set. The
  --window help text names the = form.

* refactor(cli): typed values and flag relations

Step 6 of #2228: values are parsed into their types and flag relations
are checked at parse time, so a bad value or combination is a usage
error (exit 2) naming the flag, before any work starts. The handlers
receive typed values only. Runtime failures (files, the engine, a
`{last}` with no earlier handle) stay exit 1.

- --window parses to a Bounds2 (parse_window plus cadcraft_io's
  check_window); its WindowError is the value parser's error.
- --view is a ValueEnum (extents, current), case-insensitive; the long
  help lists what each value does.
- --window conflicts_with --view. In run, --window/--view require
  --export (an ArgGroup "framing" with requires). A framing flag with
  an output that isn't an image (convert's OUT, run's --export) is a
  UsageError turned into a clap error by into_clap (ArgumentConflict).
- sample's name is a ValueEnum (bracket, floorplan); `floor` stays a
  hidden alias.
- perf's N|FILE is one typed positional: a value that parses as a
  count is one, clamped to 1..=20000000 as before (so `perf 0` still
  times one entity); anything else is a drawing.
- --cmd 'id {json}' parses to an id and a serde_json::Value (default
  {}); `{last}` is still filled in when the command runs.
- Paths are PathBufs (info FILE, convert IN/OUT, run FILEs,
  --script-file, --save, --export, sample OUT, perf FILE). The engine
  and io crates take file names as text, so a path that isn't valid
  Unicode is refused by the handler, naming the file (exit 1).
- Deleted: check_framed_output, the --view match, ArgsError,
  CliError::Window/Args/UnknownSample, RunError::CmdJson,
  PerfError::BadCount.
- run's FILEs, --sample and --metric still combine (each opens another
  drawing, the last is current): neither photocraft#7071 nor RFC-0012 requires
  otherwise.

Behaviour changes (old -> new):
- --window with a bad value (wrong count, not a number, NaN/inf,
  reversed corners, beyond 1e12): exit 1 `error: --window ...` ->
  exit 2 `error: invalid value 'V' for '--window <x0,y0,x1,y1>':
  <reason>`. The reasons no longer repeat the flag or the value.
- --view with a bad value: exit 1 `error: --view needs `extents` or
  `current`` -> exit 2 `error: invalid value 'V' for '--view
  <extents|current>'` plus clap's possible-values line.
- --window with --view: exit 1 `error: use only one of --window and
  --view` -> exit 2 `error: the argument '--window <x0,y0,x1,y1>'
  cannot be used with '--view <extents|current>'`.
- run --window/--view without --export: exit 1 `error: --window/--view
  need an image output ...` -> exit 2 `error: the following required
  arguments were not provided: --export <OUT.png>`.
- A framing flag with a non-image output: exit 1 `error:
  --window/--view only apply to image output (png, svg, pdf), not
  `OUT`` -> exit 2 `error: --window only applies to image output (png,
  svg, pdf), not `OUT`` (or --view: the message names the flag given).
- sample with an unknown name: exit 1 `error: unknown sample `X`
  (bracket, floorplan)` -> exit 2 `error: invalid value 'X' for
  '<bracket|floorplan>'` plus the possible values. sample's help lists
  the possible values.
- --cmd with JSON that doesn't parse: exit 1 `error: --cmd ID:
  <serde error>`, after the earlier steps ran -> exit 2 `error: invalid
  value 'V' for '--cmd <id {json}>': <serde error>`, before anything
  runs.
- --cmd with an empty id (`--cmd=` or `--cmd ' {}'`): exit 1 `error:
  unknown command `` ` -> exit 2 `... expected a command id, then
  optionally its JSON parameters`.
- An empty path argument (info "", sample bracket "", perf "", ...):
  exit 1 from the engine or io -> exit 2 `error: a value is required
  for '<FILE>' but none was supplied` (clap's PathBuf parser).
- A path that isn't valid Unicode: exit 2 `error: invalid UTF-8 was
  detected ...` -> exit 1 `error: PATH: the file name is not valid
  Unicode` (text values that aren't Unicode stay exit 2). A non-Unicode
  --save or --export is refused before any drawing opens.
- perf's help gives the count range.

Tests: usage.rs rows above moved to exit 2 with clap's messages; new
rows for --view with a non-image output, run's framing with a dxf
--export, an empty --cmd id, bad --cmd JSON after a good step, a
`{last}` with no earlier handle, --window beyond 1e12, case-sensitive
sample names and empty paths. The non-Unicode test covers both a path
(exit 1) and text (exit 2). Unit tests: the --window parser tests moved
to args.rs; new ones for --cmd values and perf targets; framing_options
checks the clap error kinds.

* refactor(cadcraft): move launch-argument parsing into launch_args

Moves the hand-written parser (the argument loop, control_port_from, control_flag and their tests) from main.rs into launch_args.rs, which returns LaunchArgs. No behaviour change.

* refactor(cadcraft): parse launch flags with clap (RFC-0012)

Replaces the hand-written loop in launch_args.rs with clap derive types, as photocraft's launch_args.rs does. --help is generated from the doc comments and lists the other CADCRAFT_* variables.

Behaviour changes (old -> new):
- An unknown flag (--sampel) was opened as a file; it is now a usage error on stderr, exit 2, before the window opens. A file starting with "-" goes after "--".
- A repeated --control or --sample was accepted (last wins); it is now exit 2.
- A bad port (--control abc, 70000, -1) was logged and the control channel left off; it is now exit 2. The same for CADCRAFT_CONTROL_PORT, and the error names the variable when the value came from it.
- A bare --control (no value) kept CADCRAFT_CONTROL_PORT's port with a warning; it is now exit 2 ("a value is required"). A following flag is never taken as the value.
- An empty or blank CADCRAFT_CONTROL_PORT was logged as a bad port; it is now unset.
- --control=7979 is accepted (the equals form was opened as a file before).
- --version / -V and --help stop parsing at once and exit 0; version text is unchanged ("cadcraft <version> (<sha>)").
- -psn_* (Finder process serial number) is dropped instead of opened as a file.
- A file name that is not valid Unicode used to panic in std::env::args(); it is now read from args_os and reported with a log warning, not opened, because CadApp::open_path takes text.

Copied from photocraft's crates/automation/src/args.rs: blank_env_is_unset and name_env_vars unchanged (name_env_vars gets a local use for ContextKind/ContextValue); control_port and NotAPort unchanged. allow_negative_numbers is set on --control as in photocraft, so -1 is a bad value rather than a missing one.

* feat(cli): shell completions and man pages

Adds examples/man-and-completions.rs, which writes cadcraft-cli's bash, zsh, fish, elvish and
PowerShell completions (clap_complete) and man pages (clap_mangen: cadcraft-cli.1 and one per
subcommand) into a directory. clap_complete and clap_mangen are dev-dependencies, so the binary
is unchanged.

packaging/linux/package.sh builds the example in its own cargo run, runs it (through $EMULATOR
for cross builds; warns and leaves the files out without one) and installs the man pages and the
bash, zsh and fish completions into the staged tree. nfpm.yaml already ships the whole
usr/share tree, so it needs no change.

CADCraft has no FreeBSD packaging, so there is no FreeBSD script to update. macOS and Windows
packaging are untouched.

tests/man_and_completions.rs checks the generated file names.

* ci(cli): check the command-line code with ast-grep (RFC-0012)

Adds ast-grep rules from RFC-0012, run by craft-common-actions'
reusable ast-grep workflow (Linux, no Rust build, path-filtered):

- clap-error-message: a string literal, format!, .to_string() or
  SCREAMING_CASE const passed to Command::error / Error::raw
- clap-error-message-variable: the same message built into a variable
  first, in the same function
- string-error: Result<_, String>, Err(format!(..)), Err(".."),
  Err(x.to_string())
- args-os: std::env::args(), which panics on non-Unicode arguments

Scan paths: apps/cadcraft-cli/src, apps/cadcraft/src/launch_args.rs,
apps/cadcraft/src/main.rs. Rule tests run first in CI.

Source: photocraft#9750 at d16686cd. Only the scan paths and the
workflow path filters differ from it.

Fixes: none needed. The rule tests pass (4 passed) and the scan finds
nothing in the scanned paths.

* docs(cli): cadcraft-cli page, following RFC-0012

docs/cli.md: --help is the reference; exit codes, streams, usage
errors, values starting with -, paths, completions and man pages; the
environment variables (none for cadcraft-cli, CADCRAFT_CONTROL_PORT for
the desktop app's --control); and the rules not followed yet, with the
reason. The README links it.

* feat(cli): refuse an output that replaces an input

RFC-0012 rule 1.6: nothing destructive by default.

Behaviour change: `convert a.dxf a.dxf`, `run a.dxf … --save a.dxf` and
`run a.dxf … --export a.dxf` used to overwrite the input. They are now a
usage error (exit 2, `error: …` on stderr, nothing opened or written)
unless `--in-place` is given. "Same file" is file identity (same-file
crate), so `dir/./a.dxf` and a symlink to the input count. An output
that does not exist, or a failed identity check, is not refused; the
open or write reports the real error. Replacing an existing file that
is not an input is still allowed.

Scope: `--save` and `--export` only. A save that a --cmd or script runs
(for example `saveas`) is the engine's and is not checked.

Every write the CLI makes (convert OUT, --save, --export, sample OUT) resolves
an existing output first, so writing through a symlink updates the file it
points to and the link stays a link; the atomic rename used to replace the
link with a regular file, leaving the target (with --in-place, the input)
unchanged.

* feat(cli): --compact for JSON output

RFC-0012 rule 1.3: info and commands pretty-print their JSON by
default; --compact prints it on one line. run prints one JSON line per
--cmd result (JSON lines, one result per step), as its help and the
README document, so it takes no --compact. mcp's stdout is the MCP
protocol and is unchanged.

Behaviour change: info and commands accept --compact (it was an
unknown flag, exit 2). Their default output is unchanged.

docs/cli.md says which subcommand prints which form, and lists perf's
text table (no --json) and saves a --cmd or script runs (not checked
by --in-place) under "Not followed yet".

* fix(cadcraft): one Switch parser for boolean environment switches

CADCRAFT_VSYNC, CADCRAFT_SYSTEM_CURSOR, CADCRAFT_WAYLAND and
CADCRAFT_NO_NATIVE_MENU each had their own idea of "on". They now share
cadcraft_ui_egui::switch::Switch: 1/on/true/yes is On, 0/off/false/no is
Off (case- and space-insensitive), unset or blank is Default, and any
other value is Default and logged with the variable name. The ENVIRONMENT
help text says the same for all four.

Behaviour changes (old -> new):
- CADCRAFT_WAYLAND=0 counted as on (any value did); now only On keeps
  native Wayland.
- CADCRAFT_NO_NATIVE_MENU=0 counted as on (any value did); now only On
  keeps the menus inside the window.
- CADCRAFT_SYSTEM_CURSOR treated every value except ""/0/false/off as on;
  now only the On spellings do, and an unknown value is logged.
- CADCRAFT_VSYNC is unchanged except that an unknown value is logged.

* fix(cli): keep paths as paths until a file is opened

A path is now text only where text is needed.

- cadcraft: FileNames.unreadable is Vec<PathBuf>, logged with {:?}
  (escaped bytes) instead of a lossy rendering.
- cadcraft-cli: unicode() is called only inside open(), the one place
  that sends a path to the engine as JSON. convert, sample and run
  --save/--export pass the output as &Path. write_atomic, export,
  export_framed and OutputError::Write take or hold a Path/PathBuf;
  errors use .display(), and the `wrote ...` line uses .display().
- Dropped the dead find_subcommand lookup in launch_args (LaunchArgs has
  no subcommands).
- docs/cli.md: non-Unicode names are refused only where a drawing is
  opened.

Behaviour change: an output name that is not valid Unicode used to be
refused (exit 1); it is now written under that exact name. Opening such
a drawing is still refused.

Lossy strings that remain (all feed APIs that take text and only look at
the extension, never at the path the bytes are written to):
- cadcraft_io::write / write_framed (name picks the format) in sample.rs
  and output.rs::export_framed;
- cadcraft_io::is_image in convert.rs, run.rs and args.rs (framing flag
  check);
- cadcraft_engine::cmd::file::drops_unknown in export_framed.
These go away when cadcraft_io and the engine take impl AsRef<Path>
(the A1 follow-up). A non-Unicode extension is not a known format either
way, so it is rejected as an unsupported type, as before.

* refactor(cadcraft): read the environment once into Env

The desktop app reads its CADCRAFT_* variables once at startup into
apps/cadcraft/src/env.rs `Env` and passes the values on; no library crate
reads a CADCRAFT_* variable at run time any more.

- One `VARS` table (name, help line) feeds both the reader and the
  Environment section of `cadcraft --help`. A test checks that every
  variable the reader asks for is in the table, that every table entry
  sets a field, and that the help lists every entry.
- vsync goes to `gpu::surface_config`, the system cursor to
  `CadApp::set_system_cursor_default` (Options reset and Reset Palettes go
  back to it), FONTALT/FONTFALLBACK to the `cadcraft_fonts::ttf` setters
  (re-exported as `cadcraft_engine::fonts`), the settings directory to the
  new `cadcraft_engine::settings_dir::set`, and to `Workspace::load`.
- The three copies of the settings-directory rule (main.rs, workspace.rs,
  the engine's plotter folder) are one: `settings_dir::platform`.
- `Switch::from_env` is gone; `Switch::parse` stays.
- The child-process tests for CADCRAFT_CONTROL_PORT are replaced by tests
  that inject the variable lookup (`blank_env_is_unset`) or point
  `--control`'s fallback at PATH.

cadcraft-cli: plotter configurations matter to its output (`run --cmd plot`
or a script's PLOT with a user device, and the same through `mcp`), and the
font variables change how convert/run/sample draw text. So, following
RFC-0012 rule 1.7, the variables become fallbacks of flags declared with
`env =`: --config-dir (CADCRAFT_CONFIG_DIR), --fontalt (CADCRAFT_FONTALT)
and --fontfallback (CADCRAFT_FONTFALLBACK) on convert, run, mcp and sample.

Behaviour changes:
- CADCRAFT_CONFIG_DIR set but empty counts as unset everywhere: the
  workspace file and the plotter folder use the platform folder (was:
  workspace persistence and the plotter folder off). Logs and autosave
  already treated it so.
- The workspace file and the plotter folder ignore a relative
  XDG_CONFIG_HOME, as logs and autosave did (was: used it).
- A blank CADCRAFT_LOCALE means the system's languages (was: [""]).
- cadcraft-cli: the three variables apply only to convert, run, mcp and
  sample, as flag fallbacks (was: read by the engine and fonts in every
  subcommand, perf included). A font variable that isn't valid Unicode is
  a usage error naming the flag (was: ignored).

* refactor(io): one Format enum for file extensions

`cadcraft_doc::Format` (re-exported as `cadcraft_io::Format`) is the one
place that maps a file name's extension to a format: Dxf, Dwg, Dwt, Svg,
Svgz, Png, Pdf. `from_path` is ASCII case-insensitive; no extension (or a
trailing dot) is Dxf; an unknown extension is None. `READ` and `WRITE`
list the formats `read` and `write` accept. It lives in cadcraft-doc
because the engine depends on cadcraft-io only for tests.

Each place used to match on its own lower-cased extension string and now
matches on Format, with the same results:
- io::read: svg/svgz -> SVG reader, dxf/none -> DXF reader, other ->
  try DXF, else Unsupported(ext).
- io::write_with: dxf/none -> DXF, dwg -> DWG bridge, other with binary
  -> error, rest -> write_framed.
- io::write_framed: dwt -> DWG; dxf/none/dwg -> write_with; svg, png,
  pdf -> image writers; other -> Unsupported(ext).
- io::is_image: svg, png, pdf (not svgz, which is read only).
- engine file.rs is_dxf, drops_unknown (dwg, dwt), is_drawing_file
  (dxf, dwg, dwt, none).

Behaviour change (help text only): the CLI help lists the formats from
Format::READ / Format::WRITE instead of hand-written lists. `info` and
`convert IN` now name svg and svgz as readable, and `convert`, `sample`,
`run --save` and `run --export` name dwt (and `sample` pdf) as writable.
A test checks each file argument's help names every format of its list.

* refactor: one version string for the app and the CLI

cadcraft_engine::build_version() builds "<version> (<commit>)" once; the
desktop app's --version, its start-up log line and cadcraft-cli --version
all use it. No change in output.

* refactor(cli): share the environment helpers with the CLI

Move blank_env_is_unset and name_env_vars from the desktop app into
cadcraft_mcp::args and apply them in the CLI parser.

Behaviour changes (old -> new):
- Empty or blank CADCRAFT_FONTALT / CADCRAFT_FONTFALLBACK: reached clap as
  Some("") -> counts as unset.
- A bad value from an environment variable: the error named the flag ->
  names the variable.
- CADCRAFT_CONFIG_DIR is unchanged: empty still means no settings folder.
- Desktop app: unchanged; it uses the shared helpers.

* refactor: pass paths, not text, through io, engine and open_path

cadcraft_io::{read, write, write_with, write_framed, is_image}, the engine's
drops_unknown/is_drawing_file/read_text_file and CadApp::open_path take
paths (impl AsRef<Path> / &Path). The desktop launch arguments and the CLI
no longer convert paths to text to call them.

Behaviour changes (old -> new):
- A dropped file whose name is not valid Unicode opened with a "can't
  read" error naming a mangled path; it now opens natively. (Its path is
  not remembered for QSAVE, which then asks where to save.)
- A launch argument that is not valid Unicode was logged and skipped; it
  now opens like any other.
- A .dwt export builds the .dwg name as a path, not formatted text.
- On the web, a non-Unicode path passed to open_path reports "the path
  is not valid Unicode" instead of a lossy name.
- Opening errors print path.display().

FileNames and LaunchArgs::file_names are removed; the launch tests assert
the parsed PathBufs. New io test: non-Unicode names pick the format by
extension.

* docs(cli): CADCRAFT_CONFIG_DIR is the one flag fallback where empty isn't unset

* fix(engine): refuse a drawing path that isn't valid Unicode

read_file opened such a path with no saved path, so QSAVE could not save back to it.
It now returns an open error naming the file before reading it. The ui-egui opener
already reports read_file errors like any other failed open.

* fix(cli): report a failed write of help or version

A write error on help or version output (other than a closed pipe) now exits 1 and
tries to say so on stderr; it was ignored and exited with the clap code. A closed pipe
keeps the clap exit code.

* fix(cli): --connect needs HOST:PORT

--connect example.com was accepted and failed later. It is now a usage error (exit 2)
unless the value is a non-empty host (or a bracketed IPv6 address) and a u16 port.
The host is not resolved.

* docs(cli): run prints no line for a command with no result

* refactor(cli): explicit imports and smaller functions for the stricter lints

Lints: clippy::wildcard_imports, too_many_arguments, cognitive_complexity (30).

Replaced the non-test `use super::*;` in args, commands, convert, info,
output, perf and run with explicit imports; lib.rs keeps only the imports
its non-test code uses, and its test module imports the rest itself. The
`super::*` in the test modules stays.

A clippy run with all three lints and threshold 30 reports nothing in
apps/cadcraft-cli or in code this branch added, so no function needed
splitting.
2026-10-27 21:28:53 -04:00
aiongg 3929d94d89 Merge pull request #2230 from Jeremy8776/feat/install-scope-policy
Windows installation policy: CADCraft
2026-10-27 17:06:47 -04:00
aiongg 729f27de74 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:59 +00:00
Dylan M. Taylor 2721aa8982 Enforce Rust coding standards via Clippy (#2239)
* chore(clippy): enforce workspace Rust quality gates

* refactor(doc): use explicit entity imports

* refactor(constraints): split residual evaluation

* refactor(fonts): split mtext layout

* refactor(render): use explicit entity imports

* fix(fonts): retain height for empty paragraphs

* fix(doc): repair dimension and dynamic block links

* refactor(render): split entity drawing helpers

* docs: document workspace lint inheritance

* refactor(render): isolate dimension geometry

* refactor(engine): use explicit imports

* refactor(engine): split complex edit paths into helpers

* refactor(io): split DXF read and write helpers

* refactor(ui): split canvas interaction and drawing

* refactor(ui): split command and drawing helpers

* test(i18n): split locale preference checks

* fix(ui): use current egui scope id API

* fix(dim): preserve text offset and ellipse grip behavior
2026-10-27 15:55:09 -04:00
Jeremy 360bee4dcc fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:12:39 +00:00
aiongg 26da98e320 fix(ui-egui): Ui::scope_id for the block description field (#2336)
Ui::id is deprecated in the pinned egui, so clippy -D warnings fails on main
since #2113.
2026-10-27 14:55:46 -04:00
aiongg b4a8773c3c Merge pull request #2193 from dotCooCoo/feat/measure-arc-segments
AREA and DIST: the Arc option measures arc segments
2026-10-27 13:44:25 -04:00
aiongg 180c1885bb Merge pull request #2177 from dotCooCoo/feat/layout-saveas-template
LAYOUT: SAveas saves a layout as a drawing template
2026-10-27 13:44:03 -04:00
aiongg 9d32ead9e6 Merge pull request #2175 from dotCooCoo/feat/named-viewports
View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
2026-10-27 13:43:43 -04:00
aiongg 2c73e5f063 Merge pull request #2162 from dotCooCoo/fix/area-too-large
AREA: an area, perimeter or running total past f64's range is refused
2026-10-27 13:43:23 -04:00
aiongg ec762af206 Merge pull request #2113 from dotCooCoo/feat/block-description
Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
2026-10-27 13:43:02 -04:00
aiongg 786367e9ba Merge pull request #2331 from Silvertree2010/perf/export-speed
Speed up PNG export: stroke runs of hairline polylines as one path
2026-10-27 13:41:15 -04:00
Jeremy d1847cf9a0 fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:13 +00:00
NetterSilverandClaude Opus 5.5 850dd94f3c Stroke runs of hairline polylines as one path in the rasteriser
PNG export (and everything else drawn by raster::render) stroked every
polyline primitive with its own tiny-skia stroke_path call: a new path
and a newly built paint pipeline per primitive. Hatch patterns, linetype
dashes and text strokes produce long runs of short polylines in one
colour, so that per-call setup dominated the render.

Consecutive hairline polylines (zero width, or at most one pixel when
anti-aliased; the default for PNG export) with the same colour, alpha
and width are now collected into one path and stroked once. tiny-skia
draws hairlines segment by segment straight onto the pixmap with the
same per-contour round caps, so the pixels and their blending order are
unchanged. Thicker strokes (filled outlines), fills, points, infinite
lines and polylines with non-finite or far-out screen coordinates break
the run and are drawn exactly as before.

Adds crates/io/examples/export_bench.rs, which exports examples/
apartment.dxf repeated on a grid (10x10: 5,800 entities) and reports
median/min wall time per format.

PNG export, 10x10 grid, 11 interleaved runs (Ryzen, 16 threads, Linux):
  before: median 1790.7 ms, min 1703.3 ms
  after:  median 1037.6 ms, min  926.0 ms   (-42.1 %)
A/A noise on the same setup: 1.1 %. Output PNGs are byte-identical
(bench at 10x10 and 20x20, CLI export of apartment.dxf and a 23,200-
entity drawing). New tests compare render() against stroking each
primitive on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:47:42 +01: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
aiongg 0ff1b03537 refactor(ui): use craft-ui's ComboBox for every dropdown (#2303)
* ui: use craft_ui::ComboBox so dropdown menus grow back (egui#5225)

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:26:40 -04:00
Jeremy 7b947c3383 docs(installer): say that silent installs are per-user and when the scope page appears 2026-10-27 12:26:55 +00:00
Jeremy 29e2a139db fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:00:53 +00:00
Jeremy 6795f0bd03 fix(installer): address scope and ARM64 review feedback 2026-10-27 12:00:53 +00:00
Jeremy 8581d30a7f feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:00:53 +00:00
aiongg d864a08b01 Merge pull request #2220 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:30:19 -04:00
skylar taylor-barrick c3b8e7b7fe Merge pull request #2227 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:13 -06:00
skylar b06fd039fb ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:04 -06:00
aiongg 7d2ca90783 refactor(engine): move Session methods from session.rs to session/ 2026-10-26 19:44:13 +00:00
aiongg af9798e200 refactor(doc): move entity.rs items to entity/ 2026-10-26 19:44:13 +00:00
aiongg c042e98abb refactor(render): move dim.rs items to dim/arrowheads.rs and dim/text.rs 2026-10-26 19:44:13 +00:00
aiongg 994af5cac9 refactor(render): move lib.rs items to display, pipeline, viewports, props, draw, text_entity, dim_draw, blocks 2026-10-26 19:44:13 +00:00
aiongg 970e6bb9d4 refactor(io): move dxf_read.rs items to dxf_read/ 2026-10-26 19:18:38 +00:00
aiongg 736a83464a refactor(io): move dxf_write.rs items to dxf_write/ 2026-10-26 19:18:38 +00:00
aiongg aa5dfc2099 refactor(engine): move lisp/builtins.rs items to lisp/builtins/ 2026-10-26 19:18:38 +00:00
aiongg f12b7570b7 refactor(engine): move lib.rs types to error, view, doc_state, settings, running, last_used and Session methods to session.rs 2026-10-26 19:18:38 +00:00
aiongg 795eb62762 refactor(engine): move cmd/draw.rs items to cmd/draw/ 2026-10-26 19:18:38 +00:00
aiongg e7b92c5b8e refactor(engine): move cmd/annotate.rs items to cmd/annotate/ 2026-10-26 19:18:38 +00:00
aiongg 6028cdf187 refactor(engine): move cmd/modify2.rs items to cmd/modify2/ 2026-10-26 19:18:38 +00:00
aiongg 172f9ca44c refactor(engine): move cmd/draw2.rs items to cmd/draw2/ 2026-10-26 19:18:38 +00:00
aiongg c5e7db78e4 refactor(engine): move cmd/modify.rs items to cmd/modify/ 2026-10-26 19:18:38 +00:00
aiongg 31a04e3dbe refactor(ui-egui): move canvas.rs items to canvas/ 2026-10-26 19:18:38 +00:00
aiongg 627ffb8d7e refactor(engine): move grips_tests.rs to grips/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 18f47e021f refactor(engine): move cmd/inquiry/usage_tests.rs to cmd/inquiry/usage/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 21d80b3231 refactor(engine): move cmd/txt2mtxt_tests.rs to cmd/txt2mtxt/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 62deed7880 refactor(engine): move cmd/mleader_edit_tests.rs to cmd/mleader_edit/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 45bcfa6f77 refactor(engine): move cmd/constraints_tests.rs to cmd/constraints/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 627b5a3350 refactor(engine): move cmd/annotate_tests.rs to cmd/annotate/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1006e487da refactor(engine): move cmd/modify2_tests.rs to cmd/modify2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg fcd4a0c9eb refactor(engine): move cmd/draw2_tests.rs to cmd/draw2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg dbd2bf5dad style: cargo fmt after moving test modules out 2026-10-26 18:18:07 +00:00
aiongg d7915c5f3b refactor(ui-egui): move canvas.rs tests to canvas/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1bbc5ec76b refactor(engine): move layout.rs tests to cmd/layout/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 012732136c refactor(geom): move spline.rs tests to spline/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 273303134e refactor(ui-egui): move hatchdlg.rs tests to hatchdlg/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 48d25a0414 refactor(render): move dim.rs tests to dim/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 6021d96628 refactor(ui-egui): move capture.rs tests to capture/tests.rs 2026-10-26 18:18:07 +00:00
aiongg ad329273f2 refactor(ui-egui): move crates/ui-egui/src/options/mod.rs to crates/ui-egui/src/options.rs 2026-10-26 18:18:07 +00:00
aiongg f3796d1f7a style(ui-egui): cargo fmt after the i18n.rs move 2026-10-26 18:18:07 +00:00
aiongg 3a64a190ea refactor(ui-egui): move crates/ui-egui/src/i18n/mod.rs to crates/ui-egui/src/i18n.rs 2026-10-26 18:18:07 +00:00
aiongg 5074d2a54c refactor(engine): move crates/engine/src/lisp/mod.rs to crates/engine/src/lisp.rs 2026-10-26 18:15:12 +00:00
aiongg bccba7261e refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 18:15:12 +00:00
aiongg c8ee56a44f test: scan source directories recursively
The translation gap report reads each listed UI module and every file under its directory, so code split into submodules stays in the report.
2026-10-26 17:59:21 +00: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
dotCooCoo 98a305514b AREA and DIST: the Arc option measures arc segments
AREA's next-point prompt and DIST's Multiple points prompt offered Arc,
and MEASUREGEOM's Distance, Area and Volume options ask the same
prompts, but Arc printed "Arc: not available yet". Arc now turns on arc
mode with PLINE's arc options (Angle, CEnter, CLose, Direction, Line,
Radius, Second pt, Undo; CEnter takes an end point, Angle or chord
Length), and the arcs count in the distance, area, perimeter and
volume. A point in arc mode ends an arc tangent to the
last segment, Length after an arc continues along its end tangent, and
Undo removes the last arc. CLose ends with an arc back to the first
point. A point straight behind the last segment is refused with
"Invalid arc endpoint.".

In a DIST measured through one viewport, an arc's computed end, a typed
radius and a typed chord length are in the viewport's model units, as
Length's distance is.
2026-10-25 21:17:18 -07:00
dotCooCoo 605dc43898 Merge branch 'main' into feat/named-viewports 2026-10-25 21:10:43 -07:00
aiongg d3ae8a6af0 Side panels dock in left and right dock columns (#2158)
* Pin craft-libs at craft-libs#312's head (4dc543c) for the dock-column move

* Side panels dock in left and right dock columns

Tool Sets sits in a left dock column and Layers above Properties in a right
one, at the widths of the old side frames. Palettes dock only into those
columns: a window edge without a dock makes one, and neither the drawing nor
the top and bottom edges take a drop. A palette opened with no recorded place
goes into its dock group (External References below Properties). Iconic
columns show CADCraft's own code-drawn icons.

* Workspace file: dock layout version; split-frame layouts reset to the default

The workspace file saves dockingVersion next to the panel layout. A layout
saved without it, or with an older one, is dropped with its hidden panels on
load; the other workspace settings are kept.

* Floating panels open in their own OS windows on the desktop

- craft-ui's native-windows feature is on for the desktop app (not the web build)
  and for the UI crate's tests.
- While the Save changes prompt, Drawing Recovery or another egui modal is up,
  the panels' OS windows take no input.
- Keys, text and clipboard events typed in a panel's OS window go into the main
  window's next input, so shortcuts, the command line and the canvas get them.
- Files dropped on a panel's OS window open like files dropped on the main window.

* Floating windows stay below the macOS title row

A drag in that row moves the whole app window, so a floating panel drawn over it
could not be moved or used. The dock area keeps floating windows, flyouts and
drag ghosts below it.

* Opening a floating panel from a menu or command brings its window to the front

* Workspace saves are spaced at least 500 ms apart, so dragging a floating window doesn't write the file every frame

* Pin craft-libs at #312's head b6d35e28

* Pin craft-libs at #313's head cdc67131 (result-rectangle drop preview)

* The title row is craft-window's title bar

Its empty part moves the window and follows the macOS double-click setting,
and does nothing in full screen. On macOS the window's movable flag follows
the pointer, so AppKit's title-band drag leaves the window still under the
row's controls and under floating panels. Floating panels may therefore go
over the title row again.

craft-window is pinned at craft-libs#316's head, which is not stacked on the
craft-ui pin.

* Pin craft-libs at 189b4ffe (#313 and #316 merged)

* macOS menu choices and their shortcuts run at once

A menu choice is no window event, so the app ran it only at its next frame
for another reason: File ▸ Close (Cmd+W) on an edited drawing showed
"Save changes?" and dimmed the floating palettes a second or two late. The
menu's event handler now wakes the app.

* Pin craft-libs at pin-round3 d24e4bcc

* Cargo.lock and the native-window test helper after the egui 0.37 merge

* Pin craft-libs at #334 (4638095): the title bar's empty area takes no keyboard focus

* Pin craft-libs at main 271594c0

* A modifier the main window changes while panel keys are forwarded is the one put back
2026-10-25 22:23:01 -04:00
aiongg 2a2d4e2582 macOS Edit menu keys edit a focused text field (#2139)
* macOS Edit menu keys edit a focused text field

The menu bar takes ⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z before any window sees them,
so they ran Copy, Paste or Undo on the drawing while the user was typing in a
field. While a text field has the keyboard, in the main window or a floating
panel's own window, the menu now hands the field its key instead.

craft-libs is pinned at #318's head for typing_window and send_keys.

* Edit menu items a text field takes stay enabled while one has the keyboard

Edit ▸ Paste follows the drawing clipboard, so with nothing copied in the
drawing it was greyed out even while a text field could paste.
2026-10-25 21:58:11 -04:00
Dylan M. Taylor d1cf06cbea refactor: split complex fillet, model, dimstyle and rectangle code into helpers (#2188)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
tangent_circles, Model::build_budgeted, DimStyle::apply_fields and
run_rectang2 (S3776), and replaces a redundant closure with a method
reference in menus.rs (S1612).
2026-10-25 21:50:25 -04:00
Dylan M. Taylor 7d33d9d4dd refactor: split complex input, explode, sweep, cache and options code into helpers (#2187)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
AreaM::input, explode_kind, hatch sweep, the COMPARE overlay paint and
the Files options tab (S3776).
2026-10-25 21:50:19 -04:00
Dylan M. Taylor 53080b1748 refactor: split complex matchers, scanners and solver setup into helpers (#2186)
Pure extraction, no behaviour change: same order of side effects, same
budget charges, same early returns and error values. Clears
cognitive-complexity findings on glob_inner, the PDF test ops scanner,
snap_ends, the loops inquiry run and build_budgeted (S3776).
2026-10-25 21:43:08 -04:00
Dylan M. Taylor 21c48cc05b refactor(io): split complex PDF marks and DXF readers into helpers (#2185)
Pure extraction, no behaviour change: same order of side effects, same
early returns and error values. Clears cognitive-complexity findings on
pdf::marks, dxf_read::read_budgeted, parse_entities,
entity_with_table_budget and Objects::apply (S3776).
2026-10-25 21:43:04 -04:00
aiongg 47237bd4dd chore(deps): safe dependency refresh (#2179)
* chore(deps): safe dependency refresh

* Hold acadrust at 0.6.6: 0.6.7 reads nothing from a damaged DWG
2026-10-25 21:15:06 -04:00
dotCooCoo c22ad3229f LAYOUT: SAveas saves a layout as a drawing template
LAYOUT SAveas printed "LAYOUT SAveas is not available yet." It now asks
for the layout (the current one by default) and the template file
(<layout>.dwt beside the drawing by default), and writes the layout as a
template: its page setup and paper space objects, viewports included, no
model space objects and no other layout. Named objects nothing in it uses
are left out, as PURGE All removes them. Layers the layout's viewports
freeze or recolor are kept, with those settings. The open drawing is not
changed.

The JSON form is layout.saveas {name?, path}, also reached as LAYOUT
{option: saveas}. A path without an extension gets .dwt. The command
also writes .dwg and .dxf paths and refuses other file types.
2026-10-25 17:07:28 -07:00
Dylan M. Taylor 2521605713 refactor: explicit imports instead of wildcards (S2208, batch_02) (#2039)
* refactor: replace wildcard imports with explicit ones (S2208, batch_02)

* Fix unused and missing imports the batch's CI gate reported

Remove two unused imports and restore the json macro and resolve_alias
in test modules that the explicit import lists had dropped.

* fix: keep the imports the explicit lists still need after the rebase

* fix(draw2): import machines, distinct_points and ok from cmd
2026-10-25 19:52:19 -04:00
Dylan M. Taylor f56be09993 refactor: explicit imports instead of wildcards (S2208, batch_04) (#2041)
* refactor: replace wildcard imports with explicit ones (S2208, batch_04)

* refactor: replace wildcard imports with explicit ones (S2208, batch_04)

* Format imports so the CI fmt gate passes

* fix: import f64_req in inquiry.rs
2026-10-25 19:37:00 -04:00
Dylan M. Taylor 1b6861698c refactor: cut test helper and two DXF writer closures to 7 parameters (S107) (#2048) 2026-10-25 19:22:31 -04:00
Dylan M. Taylor f24af47ffb refactor: cut helper signatures to 7 parameters (S107) (#2047)
* refactor: cut four helper signatures to 7 parameters (S107)

* fix: item_json params

* fix: keep the eight-argument dim calls to seven after rebase
2026-10-25 19:21:26 -04:00
Dylan M. Taylor 055b06a2f4 refactor: explicit imports instead of wildcards (S2208, batch_08) (#2045)
* refactor: replace wildcard imports with explicit ones (S2208, batch_08)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:22 -04:00
Dylan M. Taylor 660a7881d1 refactor: explicit imports instead of wildcards (S2208, batch_07) (#2044)
* refactor: replace wildcard imports with explicit ones (S2208, batch_07)

* Restore imports that the cadcraft batch dropped so the engine compiles

Explicit import lists missed names the files still use. Restore them and
the test-module globs.
2026-10-25 19:21:17 -04:00
Dylan M. Taylor 3d8301b965 refactor: explicit imports instead of wildcards (S2208, batch_06) (#2043)
* refactor: replace wildcard imports with explicit ones (S2208, batch_06)

* Restore imports that the cadcraft batch dropped so the engine compiles

The batch replaced glob imports with explicit lists that missed names
the files still use, and pointed some sub-module imports at the wrong
level. Restore the required imports and the test module globs.

* Format pedit_vertex imports so the CI fmt gate passes
2026-10-25 19:21:13 -04:00
Dylan M. Taylor cb8b1709e9 refactor: explicit imports instead of wildcards (S2208, batch_05) (#2042)
* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* refactor: replace wildcard imports with explicit ones (S2208, batch_05)

* Format imports so the CI fmt gate passes
2026-10-25 19:21:08 -04:00
Dylan M. Taylor 66ecce06f5 refactor: explicit imports instead of wildcards (S2208, batch_01) (#2038)
* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* refactor: replace wildcard imports with explicit ones (S2208, batch_01)

* Format imports so the CI fmt gate passes
2026-10-25 19:19:58 -04:00
dotCooCoo 7d0d475aee View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
-VPORTS saved, restored and deleted named model space viewport
configurations, and MVIEW Restore placed one in a layout, but View >
Viewports > Named Viewports... had no command. The new command
vports.named runs from the menu item. Typed or from the menu it opens
the Named Viewports dialog: the saved configurations, a preview of the
selected one's tiles, and in the Model tab the name of the configuration
whose tiles are where the Model tab's are, else *Active. Restore shows
the configuration in the Model tab, and in a layout it starts MVIEW
Restore with the name, which asks for the corners. Delete deletes the
saved configuration. The JSON form takes {name?, delete?, p1?, p2?,
layout?}, and without a name it returns the configurations and the
current one.

Catalog rows for the new menu label are added in all 15 languages. In
docs/gaps.md, U11 is narrowed, and F15, U11 and F21 no longer list MVIEW
Restore (#633) or PURGE of multiline styles, which are on main.
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-25 16:07:11 -07:00
Dylan M. Taylor 004287d919 refactor: explicit imports instead of wildcards (S2208, batch_03) (#2040)
* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* refactor: replace wildcard imports with explicit ones (S2208, batch_03)

* Format imports so the CI fmt gate passes
2026-10-25 18:18:33 -04:00
Dylan M. Taylor 656dd4d09a refactor: explicit imports instead of wildcards (S2208, batch_00) (#2037)
* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

Explicit lists now resolve the names each file uses, and the test
module keeps its glob so it still sees the parent's items.
2026-10-25 18:18:29 -04:00
Dylan M. Taylor 876de8ad2f build: pin craft-update to craft-libs main (495e597) (#2168)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:38 -04:00
Dylan M. TaylorandCodebuff 4feccf0abd build: pin craft-update to craft-libs main (495e597) (#2163)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:01 -04:00
dotCooCoo 48c4a5e9d5 AREA: an area, perimeter or running total past f64's range is refused
AREA reported an area or perimeter past f64's range as "Area = 0" (the
JSON form returned `"area": null`), and Add area or Subtract area could
push the running total past it, after which every total read
"Total area = 0". In MEASUREGEOM's Volume option, such an area waited
for a height that could never give a volume, so every later area in
Add or Subtract mode waited behind it.

`area` now returns "the area is too large" or "the perimeter is too
large". Typed, AREA echoes the same message instead of the result, and
an area that would carry the total past f64's range echoes "The total
area is too large." and leaves the total as it was. The Volume option
echoes "The area is too large." and does not ask for a height for that
area.
2026-10-25 13:19:12 -07:00
Dylan M. Taylor dfddd13656 build: pin craft-update to craft-libs main (db19c37) (#2161)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:33:55 -04:00
Dylan M. Taylor 2f82ecdbf1 feat: startup update prompt modal and direct self-updating (#2141)
* feat: startup update prompt modal and direct self-updating

Ref: storytold/craft-libs#319

* build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:52:54 -04:00
aiongg 7fc9f2756d Every craft-libs crate at craft-libs main a0916808 (#2153)
craft-ui and craft-docpath were pinned at intermediate commits of the egui
0.37 port (craft-libs#268), and the PDF stack at 6e7f285. a0916808 is main
after #268 merged and contains all three.
2026-10-25 14:02:27 -04:00
Dylan M. Taylor 754b609d09 Merge pull request #1992 from storytold/dependabot/cargo/cargo-dependencies-29f2f66057
chore(deps): bump lyon_tessellation from 1.0.22 to 1.0.23 in the cargo-dependencies group across 1 directory
2026-10-25 12:30:03 -04:00
d6e712db41 CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912) (#982)
* CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912)

* cli: write through the engine's atomic_write instead of a second copy

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:25:51 -04:00
MarekSpodBiedry d73e379fd2 Autosave no longer writes the old host as recovery when the Block Editor's changes can't be composed (#918) (#980) 2026-10-25 12:25:48 -04:00
MarekSpodBiedry 8343045863 Fix grid index span overflow with a very large view (#920) (#979) 2026-10-25 12:25:44 -04:00
MarekSpodBiedry 2528671106 Cap HATCH spline edge degree at 10 like the SPLINE reader (#923) (#978) 2026-10-25 12:25:41 -04:00
MarekSpodBiedry 3f92d57b79 Geometry: circle intersection judges tangency against the smaller circle, so a small circle crossing a huge one gives two points (#926) (#976) 2026-10-25 12:25:38 -04:00
MarekSpodBiedry c3c455fbbf Region: test very long segments against every segment so no crossing is missed (#929) (#975) 2026-10-25 12:25:34 -04:00
MarekSpodBiedry d0a1ea5268 Table commands reject a malformed handle instead of using the selected table (#934) (#970) 2026-10-25 12:25:31 -04:00
2994b52e03 Read dynamic block element tags from the entity's own xdata (#942) (#967)
* Read dynamic block element tags from the entity's own xdata (#942)

* dynblock test: an untagged object at index 1 is keyed 1_000_001

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:25:28 -04:00
0de051049c Keep associative-array parameters when an xref is loaded (#943) (#966)
* Keep associative-array parameters when an xref is loaded (#943)

* xref load test: initialise Ctx's new bytes counter

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:25:24 -04:00
cddf09beb2 DXF/DWG: read the drawing's WIPEOUTFRAME from its WIPEOUTVARIABLES object (#908)
AutoCAD stores the wipeout frame setting in the drawing's WIPEOUTVARIABLES object (group 70:
0 = frames hidden). CADCraft ignored it and always showed the frames (the default of 1), so
masks such as the diamond inside a numbered circle were outlined. The flag is now read into
the WIPEOUTFRAME setting.

Co-authored-by: simanov <asimfali@mail.ru>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:21 -04:00
1c85d339c3 Reject negative or out-of-range minutes/seconds in D'" angles (#2116)
Found while testing crates/engine/src/units.rs:333-340 (parse_dms)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-25 12:15:42 -04:00
63a5569970 In-place text editing accepts IME composition and commit (#1905)
* ui: accept IME composition and commit in the command line (#1761)

The command line is a custom buffer with keyboard focus staying on the
drawing, so no egui TextEdit ever owned the IME: egui never published an
IME output, winit never called set_ime_allowed(true), and a Windows IME
(Microsoft Pinyin, Japanese, Korean) could not even switch on. Even when
IME events arrived they were ignored, so in-place single-line TEXT edits
(and every other text prompt) accepted ASCII only.

- Publish an IME output (command-line rect and caret) while a prompt asks
  for text, enabling the OS IME; release it when the prompt no longer
  collects text, so ASCII typing and shortcuts keep their behaviour.
- Handle Event::Ime: Preedit is drawn as an underlined composition after
  the buffer but never saved; Commit inserts the committed string once.
  Newline/Return commits are ignored like egui's own text fields, and
  Enter/Escape/submit clear any composition.

Tests: a headless command-line test asserts the IME is enabled for a
TEXTEDIT prompt, the preedit stays out of the buffer, the commit lands
exactly once and the text entity changes; a kittest double-click test
edits TEXT in place with IME preedit and commit.

* cmdline IME: build the IMEOutput with egui 0.37's constructor and a context generation

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:13:56 -04:00
Nicholas Gower 5acec7d0be PLOTWINDOW: pick both corners even with ORTHO on (#1893)
* PLOTWINDOW: pick both corners even with ORTHO on

PLOTWINDOW's opposite-corner prompt used Prompt::base, so the canvas bent
the picked point onto the nearest axis with ORTHOMODE on: the second corner
landed above/beside the first, the window had no width or height, and
set_plot_window refused it. The prompt now uses Prompt::corner, the flag
ZOOM Window, RECTANG and ARRAYRECT already use to keep a rectangle's
opposite corner free of ortho. The first corner sets the base as before, so
the rubber band and direct distance entry still work.

Regression test drives the interactive machine through snap_cursor with
ORTHOMODE on, picks two non-axis-aligned corners and checks the stored
window keeps both extents; an ordinary LINE prompt still follows ortho.

Fixes #1641

* docs: document the functions touched by #1641
2026-10-25 12:13:53 -04:00
Jonasandcloudj 8f7421737d fix(ui): keep layout tabs stable when toggling Tool Sets (#2008)
Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-25 12:13:49 -04:00
skylar taylor-barrickandskylartaylor 422d42411a fix: omit empty outlines in complex text shaping (#1960)
* fix: filter empty outlines in complex shaping

* docs: describe complex shaping and its empty-glyph regression

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-25 12:13:45 -04:00
Patrik LermonandClaude Opus 5.5 0c15f7106c i18n(sv): translate 49 new strings (#2120)
Brings the Swedish catalog up to date with upstream main (e86d895).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:13:42 -04:00
NetterSilverandClaude Haiku 5.5 31de470926 Fall back to decimal for huge engineering-unit distances (#2114)
Found while testing crates/engine/src/units.rs:18-27 (format_distance, LUNITS 3 branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:39 -04:00
Marco Arnulfo 0f60269868 MCP: resend once when the app refuses a token read before --launch opened it (fixes #2087) (#2098) 2026-10-25 12:13:35 -04:00
Marco Arnulfo 3e6e1e534d Constraints: an ellipse solution stored with its axes swapped or mirrored must still meet every constraint (an axis end by index does not) (#2082) 2026-10-25 12:13:31 -04:00
NetterSilverandClaude Haiku 5.5 52aae9c1fb Fix wcmatch returning nil for patterns with many '*' wildcards (#2078)
Found while testing crates/engine/src/lisp/builtins.rs:798-800 (glob_inner depth limit; depth is incremented per '*' at line 808)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:28 -04:00
Marco Arnulfo a89be5f2dd A Fix on a derived point (a line's midpoint, an arc's end) holds the position it had before the edit (#2077) 2026-10-25 12:13:25 -04:00
NetterSilverandClaude Haiku 5.5 a4a3a342a0 Fix format_angle zeroing radians near a full turn at low precision (#2073)
Found while testing crates/engine/src/units.rs:124-127 (wrap_full_turn, used at line 106 for AUNITS 3 and at lines 105 and 120)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:21 -04:00
Marco Arnulfo 36fcc896f7 GETVAR CECOLOR reads back as text, as in AutoCAD (follow-up to #2014) (#2069)
CECOLOR now reads "BYLAYER", "BYBLOCK", the index as text ("1" after
"red") or "RGB:r,g,b" for a true colour, from getvar, LISP and the
SETVAR ? list, where it was the ACI number (256, 0, 1, or the nearest
index of a true colour). The header still keeps the index and
CECOLOR_RGB, so DXF output and new objects are unchanged.

SETVAR CECOLOR takes "RGB:r,g,b" too, so a value saved with getvar
can be set back, true colour included.
2026-10-25 12:13:18 -04:00
NetterSilverandClaude Haiku 5.5 a0a691e1f7 Reject leading '+' in hex handle and colour parsing (#2067)
Found while testing crates/doc/src/entity.rs:21

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:14 -04:00
NetterSilverandClaude Haiku 5.5 097c53bee3 Ignore last vertex bulge in open Polyline::centroid (#2053)
Found while testing crates/geom/src/curve.rs:568-581 (Polyline::centroid); compare area() at 533-538

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:11 -04:00
NetterSilverandClaude Haiku 5.5 5ec3a18aa7 Escape literal \U+ and \M+ backslashes in the DXF writer (#2050)
Found while testing crates/dxf/src/lib.rs:511-524 (escape_unicode) and 226-258 (unescape_unicode, applied in parse_ascii_with_limit at 278)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 12:13:07 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 b37b7ceb7b refactor: replace wildcard imports with explicit ones (S2208, batch_09) (#2046)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:13:04 -04:00
aiongg 1d9ddd2406 "Save changes?" and Drawing Recovery appear at once (#2144)
An egui modal showed a frame late (its first frame only measured it) and
then faded in, dim included, over 0.2 s. These alerts answer a key press or
menu choice, so they now skip the fade, and their first show reruns the pass
with the measured size, which draws them in the first frame presented.
2026-10-25 12:07:54 -04:00
aiongg 0a14fc7d3a macOS menu choices and their shortcuts run at once (#2140)
* macOS menu choices and their shortcuts run at once

A menu choice is no window event, so the app ran it only at its next frame
for another reason: File ▸ Close (Cmd+W) on an edited drawing showed
"Save changes?" and dimmed the floating palettes a second or two late. The
menu's event handler now wakes the app.

* cargo fmt after merging main
2026-10-25 12:07:15 -04:00
aiongg bbbd9eec51 craft-pdf and its PDF backends back at craft-libs 6e7f285 (#2147)
The egui 0.37 migration moved craft-pdf, krilla, hayro-syntax and
hayro-jbig2 to 7895f5e, 206 commits older, though none of them depends on
egui. 6e7f285 keeps krilla patch 13 (non-finite link and page-box geometry
refused), craft-libs#282 (URI links only for http(s) and mailto) and
craft-libs#255 (CCITT and JPEG 2000 decode bounds). The embedded ICC
profiles are identical at both revisions.
2026-10-25 12:06:35 -04:00
skylar taylor-barrick f2b35e957e Migrate CADCraft to egui 0.37 (#2119)
* egui037: migrate CADCraft UI and preserve font notices

* M0: clarify screenshot cancellation after issue

* Document migration UI contracts and regression assertions
2026-10-25 10:48:48 -04:00
Brandon ThomasandClaude Opus 5.5 1fcf654ea8 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:26:26 -04:00
dotCooCoo 57f828d04c Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
No command changed an existing block's description; it could only be
given when the block was made or redefined (BLOCK, -BLOCK, entmake).
blocks.description (Modify > Object > Block Description...) changes
it: the block is the one the Block Editor edits, else the selected
reference's, else a name typed or a reference picked. In the Block
Editor, Properties with nothing selected shows the block's name and an
editable Description; BSAVE saves the new description with the block,
BCLOSE without saving drops it, and BSAVEAS gives it to the new block
only. A description is one line of up to 255 characters, and BLOCK and
-BLOCK now refuse one that is longer or has a line break.

Catalog rows for the new menu label are added in all 15 languages, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 21:11:10 -07: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
dependabot[bot] d0e83aefa6 chore(deps): bump lyon_tessellation
Bumps the cargo-dependencies group with 1 update in the / directory: [lyon_tessellation](https://github.com/nical/lyon).


Updates `lyon_tessellation` from 1.0.22 to 1.0.23
- [Release notes](https://github.com/nical/lyon/releases)
- [Commits](https://github.com/nical/lyon/commits)

---
updated-dependencies:
- dependency-name: lyon_tessellation
  dependency-version: 1.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 17:02:24 +00: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
535 changed files with 59016 additions and 30823 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+26
View File
@@ -0,0 +1,26 @@
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
# Seconds, no Rust build.
name: ast-grep
on:
push:
branches: [main]
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
pull_request:
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ast-grep-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
# Tracked at @main like the other shared workflows.
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
with:
config: lint/ast-grep/sgconfig.yml
paths: apps/cadcraft-cli/src apps/cadcraft/src/launch_args.rs apps/cadcraft/src/main.rs
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# 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/freebsd.yml@main
with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# 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/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
with:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+2
View File
@@ -30,3 +30,5 @@ jobs:
with:
app-name: cadcraft
app-dir: CADCraft
install-relative-path: Programs/ArtCraft/CADCraft
install-scope: perUser
+12 -12
View File
@@ -2,8 +2,6 @@
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
@@ -12,14 +10,14 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **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`).
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- **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
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
@@ -47,22 +45,24 @@ 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. 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)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
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.
+14 -8
View File
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
@@ -43,14 +48,14 @@ Generated-in-code assets are original and have no file to list:
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
## Interface translations
@@ -60,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 |
+10 -7
View File
@@ -2,8 +2,6 @@
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
@@ -12,14 +10,14 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **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`).
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- **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
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
@@ -46,4 +44,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. 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
+1183 -215
View File
File diff suppressed because it is too large Load Diff
+45 -15
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.12.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
@@ -25,13 +25,17 @@ cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
# foundation
thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
@@ -53,26 +57,28 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
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: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
@@ -88,3 +94,27 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+30 -4
View File
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
is available from crates.io and the repository above.
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
each under the SIL Open Font License 1.1.
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+6 -3
View File
@@ -123,8 +123,7 @@ numbers):
## Quick start
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
remains Rust 1.90.
CADCraft requires Rust 1.95 or later.
```sh
git clone https://github.com/storytold/cadcraft
@@ -197,7 +196,7 @@ crashed survives the next start. The file stops growing at 16 MiB. `--version` w
| Variable | Effect |
|---|---|
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) |
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`); empty means no settings folder (no log file, autosave, workspace or plotters) |
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. |
The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead.
@@ -239,6 +238,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
[docs/customization.md](docs/customization.md).
`cadcraft-cli --help` documents every subcommand and flag; exit codes, streams, environment
variables and its RFC-0012 conventions are in [docs/cli.md](docs/cli.md).
## Architecture
```text
@@ -312,6 +314,7 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
Installers and executables are code-signed.
The installer adds CADCraft to the Start menu and asks whether to add a desktop shortcut too (unticked by default). For a silent install with the desktop shortcut: `msiexec /i cadcraft-<ver>-windows-x64.msi /qn DESKTOPSHORTCUT=1`.
A silent install is per-user by default; add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+3 -3
View File
@@ -125,8 +125,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
@@ -144,7 +144,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
## Shared UI workspace integration
+17
View File
@@ -6,6 +6,14 @@ edition.workspace = true
license.workspace = true
publish = false
[lib]
name = "cadcraft_cli"
path = "src/lib.rs"
[[bin]]
name = "cadcraft-cli"
path = "src/main.rs"
[lints]
workspace = true
@@ -14,4 +22,13 @@ cadcraft-engine = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-mcp = { workspace = true }
cadcraft-render = { workspace = true }
clap = { workspace = true }
same-file = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
# Shell completions and man pages (examples/man-and-completions.rs, for the Linux packages); not
# in the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
@@ -0,0 +1,41 @@
//! Writes cadcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `cadcraft-cli.1` and one per subcommand, such as
//! `cadcraft-cli-convert.1`) into a directory. The Linux packages ship them
//! (`packaging/linux/package.sh`):
//!
//! ```sh
//! cargo run -p cadcraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = cadcraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "cadcraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`cadcraft-cli-help-convert.1`).
clap_mangen::generate_to(cadcraft_cli::command(), dir)
}
+786
View File
@@ -0,0 +1,786 @@
//! The command line, as clap types. Values are parsed into their types here and the relations
//! between flags are checked, so a bad value or combination is a usage error (exit 2) naming the
//! flag, before any work starts; the handlers receive typed values only. A flag a subcommand
//! doesn't take is a usage error too, so a typo can't be silently ignored. A value flag never takes
//! the next flag as its value: a value starting with `-` is written `--save=-out.dxf` (or
//! `./-out.dxf`).
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_io::Format;
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
use clap::builder::{OsStringValueParser, PathBufValueParser, TypedValueParser};
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use serde_json::Value;
/// Help text that names the formats of `list`, built once from [`Format::READ`] or [`Format::WRITE`]
/// so it can't drift from what the code accepts.
fn with_formats(cell: &'static std::sync::OnceLock<String>, before: &str, list: &[Format], after: &str) -> &'static str {
cell.get_or_init(|| format!("{before}({}){after}", Format::list(list)))
}
/// The drawing an input argument names.
fn read_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "The drawing to read ", Format::READ, "")
}
fn open_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Drawings to open ", Format::READ, "")
}
fn write_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "The file to write; its extension picks the format ", Format::WRITE, "")
}
fn save_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Save the drawing to this file; its extension picks the format ", Format::WRITE, "")
}
fn export_help() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Export the drawing to this file; its extension picks the format ", Format::WRITE, "")
}
fn convert_about() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
with_formats(&C, "Convert a drawing; OUT's extension picks the format ", Format::WRITE, "")
}
fn convert_long_about() -> &'static str {
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
C.get_or_init(|| {
format!(
"{}\n\nImage output (png 2400x1600, svg, pdf) shows model space, fitted to the drawing extents unless\n--window or --view says otherwise.",
convert_about()
)
})
}
/// cadcraft-cli: headless CADCraft
#[derive(Parser)]
#[command(name = "cadcraft-cli", version = cadcraft_engine::build_version(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Info(InfoArgs),
Convert(ConvertArgs),
Run(RunArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
}
/// Print a drawing's summary as JSON
#[derive(Args)]
pub(crate) struct InfoArgs {
#[arg(help = read_help())]
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Convert a drawing; OUT's extension picks the format
#[derive(Args)]
#[command(about = convert_about(), long_about = convert_long_about())]
pub(crate) struct ConvertArgs {
#[arg(value_name = "IN", help = read_help())]
pub(crate) input: PathBuf,
#[arg(value_name = "OUT", help = write_help())]
pub(crate) output: PathBuf,
#[command(flatten)]
pub(crate) framing: FramingArgs,
/// Allow an output that is an input file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// The settings the desktop app reads from its environment, for the subcommands that draw or run
/// commands. A variable is the flag's fallback; an empty one counts as unset, except
/// `CADCRAFT_CONFIG_DIR`.
#[derive(Args)]
#[command(next_help_heading = "Settings")]
pub(crate) struct Settings {
/// The settings folder; plotter configurations are in its `plotters` folder (default: the
/// platform's settings folder). An empty value means no settings folder
// Not RFC-0012 rule 1.7's "an empty variable counts as unset": an empty value is the
// documented way to run with no settings folder (isolated runs, tests), and it predates the
// CLI port. Keep it out of any blanket treatment of empty variables.
#[arg(long, value_name = "DIR", env = "CADCRAFT_CONFIG_DIR", value_parser = OsStringValueParser::new().map(PathBuf::from))]
pub(crate) config_dir: Option<PathBuf>,
/// The font that replaces a missing one (FONTALT)
#[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")]
pub(crate) fontalt: Option<String>,
/// The fonts tried, in order, for characters a font lacks, comma-separated (FONTFALLBACK)
#[arg(long, value_name = "FONTS", env = "CADCRAFT_FONTFALLBACK")]
pub(crate) fontfallback: Option<String>,
}
impl Settings {
/// Install the settings for this run; an unset one is the default. An empty or blank
/// `--config-dir` is no settings folder.
pub(crate) fn apply(&self) {
cadcraft_engine::settings_dir::set(self.settings_dir());
cadcraft_engine::fonts::ttf::set_font_alt(self.fontalt.as_deref().unwrap_or(""));
cadcraft_engine::fonts::ttf::set_fallback_fonts(self.fontfallback.as_deref().unwrap_or(""));
}
/// The settings folder: `--config-dir`, else the platform's; `None` when the flag is empty or
/// blank.
fn settings_dir(&self) -> Option<PathBuf> {
match &self.config_dir {
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
Some(dir) => Some(dir.clone()),
None => cadcraft_engine::settings_dir::platform(|k| std::env::var_os(k)),
}
}
}
// What part of model space an image shows: shared by `convert` and `run`.
#[derive(Args)]
pub(crate) struct FramingArgs {
/// Show this rectangle (drawing units, x0<x1, y0<y1), centred and scaled to fit the image
/// without distortion. A negative x0 needs the `=` form: --window=-10,0,10,5
#[arg(long, value_name = "x0,y0,x1,y1", value_parser = parse_window, conflicts_with = "view")]
pub(crate) window: Option<Bounds2>,
/// What part of model space the image shows (case-insensitive)
#[arg(long, value_name = "extents|current", value_enum, ignore_case = true)]
pub(crate) view: Option<ViewMode>,
}
/// `--view`.
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub(crate) enum ViewMode {
/// Always fit the drawing extents
Extents,
/// The current view, as last set by zoom, pan or view.set (a freshly opened drawing's view is
/// its extents plus a 10% margin)
Current,
}
/// Open drawings (or start a new one), run scripts and commands in order, save or export the result
///
/// Drawings open in the order given, and the last one is current; without any, a new imperial
/// drawing is used. --script, --script-file and --cmd steps run in the order given, and each
/// --cmd prints its JSON result on one line (JSON lines); a command with no result prints nothing.
/// An image --export (png 2400x1600, svg, pdf) shows model space:
/// the current view if the steps changed it (zoom, pan, view.set…), otherwise the drawing
/// extents, unless --window or --view says otherwise.
#[derive(Args)]
#[command(group(ArgGroup::new("framing").args(["window", "view"]).multiple(true).requires("export")))]
#[command(after_long_help = r#"--cmd chaining:
A --cmd parameter value of exactly "{last}" is replaced with the last object handle an
earlier --cmd in the same run returned, so a command can act on what the previous one made:
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' prints {"viewports":["100"]}
--cmd 'viewport.set {"handle":"{last}","scale":1}' changes viewport 100
It works in any parameter, at any depth (e.g. "handles":["{last}"]); every other string is
passed unchanged. With no earlier returned handle the run fails and says so."#)]
pub(crate) struct RunArgs {
#[arg(value_name = "FILE", help = open_help())]
pub(crate) files: Vec<PathBuf>,
/// Open the built-in sample drawing
#[arg(long)]
pub(crate) sample: bool,
/// Start a new metric drawing
#[arg(long)]
pub(crate) metric: bool,
/// Command-line script text to run (`\n` separates lines)
#[arg(long, value_name = "TEXT", action = ArgAction::Append)]
pub(crate) script: Vec<String>,
/// A script file (.scr) to run
#[arg(long, value_name = "F.scr", action = ArgAction::Append)]
pub(crate) script_file: Vec<PathBuf>,
/// An engine command and its JSON parameters (default {}); see "--cmd chaining" in --help
#[arg(long, value_name = "id {json}", action = ArgAction::Append, value_parser = cmd_step)]
pub(crate) cmd: Vec<CmdStep>,
#[arg(long, value_name = "OUT.dxf", help = save_help())]
pub(crate) save: Option<PathBuf>,
#[arg(long, value_name = "OUT.png", help = export_help())]
pub(crate) export: Option<PathBuf>,
#[command(flatten)]
pub(crate) framing: FramingArgs,
/// Allow an output that is an input file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) settings: Settings,
/// The drawings, in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) drawings: Vec<Drawing>,
/// The steps, in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) steps: Vec<Step>,
}
/// A drawing `run` opens.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Drawing {
File(PathBuf),
Sample,
Metric,
}
/// A `--script`, `--script-file` or `--cmd` of `run`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Step {
Script(String),
ScriptFile(PathBuf),
Cmd(CmdStep),
}
/// `--cmd 'id {json}'`: an engine command and its parameters. `{last}` placeholders are filled in
/// when it runs.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CmdStep {
pub(crate) id: String,
pub(crate) params: Value,
}
/// Print the command catalog as JSON
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Only the commands whose id or label contains this text (case-insensitive)
pub(crate) filter: Option<String>,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Run the MCP server on stdio: a headless drawing session, or a bridge to the running app
#[derive(Args)]
pub(crate) struct McpArgs {
/// Bridge to the app's control channel (its --control port, e.g. 127.0.0.1:7979)
#[arg(long, value_name = "HOST:PORT", value_parser = connect_addr)]
pub(crate) connect: Option<String>,
/// Reach the app at the first call instead of at startup, so the server can start before the
/// app is open
#[arg(long, requires = "connect")]
pub(crate) lazy: bool,
/// Like --lazy, and a call that finds nothing at HOST:PORT opens the app with that control
/// port first (macOS)
#[arg(long, requires = "connect")]
pub(crate) launch: bool,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list
/// build of a drawing, zoomed in step by step
#[derive(Args)]
pub(crate) struct PerfArgs {
/// An entity count (clamped to 1-20000000), or a drawing to time
#[arg(value_name = "N|FILE", value_parser = PathBufValueParser::new().map(perf_target))]
pub(crate) target: Option<PerfTarget>,
}
/// What `perf` times.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum PerfTarget {
/// A synthetic drawing with this many entities, within [`MAX_PERF_COUNT`].
Count(usize),
File(PathBuf),
}
/// The largest synthetic drawing `perf` builds.
const MAX_PERF_COUNT: usize = 20_000_000;
/// `perf N|FILE`: a value that reads as a count is one, clamped to 1..=[`MAX_PERF_COUNT`] so `perf
/// 0` still times something; anything else is a drawing.
fn perf_target(p: PathBuf) -> PerfTarget {
match p.to_str().and_then(|t| t.parse::<usize>().ok()) {
Some(n) => PerfTarget::Count(n.clamp(1, MAX_PERF_COUNT)),
None => PerfTarget::File(p),
}
}
/// Write a built-in sample drawing
#[derive(Args)]
pub(crate) struct SampleArgs {
/// Which sample
#[arg(value_name = "bracket|floorplan", value_enum)]
pub(crate) name: SampleName,
#[arg(value_name = "OUT", help = write_help())]
pub(crate) output: PathBuf,
#[command(flatten)]
pub(crate) settings: Settings,
}
/// `sample`'s drawings.
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub(crate) enum SampleName {
Bracket,
#[value(alias = "floor")]
Floorplan,
}
/// What part of model space an image export shows.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Framing {
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
Auto,
Extents,
Current,
Window(Bounds2),
}
/// Why a `--window` value is refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum WindowError {
#[error("expected four numbers x0,y0,x1,y1")]
NeedsFour,
#[error("{name} must be a finite number, got `{value}`")]
NotFinite { name: &'static str, value: String },
#[error("need x0 < x1 and y0 < y1 (lower-left corner first)")]
Order,
#[error(transparent)]
Limits(#[from] cadcraft_io::IoError),
}
/// Why a value is refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
pub(crate) enum ValueError {
#[error("expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")]
Connect,
#[error("expected a command id, then optionally its JSON parameters")]
CmdId,
#[error(transparent)]
CmdJson(#[from] serde_json::Error),
}
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
#[derive(Debug, thiserror::Error)]
enum UsageError {
/// The framing flags choose what an image shows; a drawing file has nothing to frame.
#[error("{flag} only applies to image output (png, svg, pdf), not `{}`", .out.display())]
FramingNeedsImage { flag: &'static str, out: PathBuf },
/// The output is one of the input files, so writing it would replace the original.
#[error("{} is an input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
OutIsInput(PathBuf),
}
impl UsageError {
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
let kind = match self {
Self::FramingNeedsImage { .. } | Self::OutIsInput(_) => ErrorKind::ArgumentConflict,
};
cmd.error(kind, self)
}
}
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
pub(crate) fn parse_window(text: &str) -> Result<Bounds2, WindowError> {
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
let [x0, y0, x1, y1] = parts.as_slice() else {
return Err(WindowError::NeedsFour);
};
let num = |name: &'static str, v: &str| -> Result<f64, WindowError> {
match v.parse::<f64>() {
Ok(n) if n.is_finite() => Ok(n),
_ => Err(WindowError::NotFinite { name, value: v.to_string() }),
}
};
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
if !(x0 < x1 && y0 < y1) {
return Err(WindowError::Order);
}
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
cadcraft_io::check_window(&w)?;
Ok(w)
}
/// `run --cmd 'id {json}'`: the id up to the first space, then JSON parameters (default `{}`).
/// Whether they suit the command is the command's to say.
fn cmd_step(s: &str) -> Result<CmdStep, ValueError> {
let (id, json) = s.split_once(' ').unwrap_or((s, "{}"));
if id.is_empty() {
return Err(ValueError::CmdId);
}
Ok(CmdStep { id: id.to_string(), params: serde_json::from_str(json)? })
}
impl FramingArgs {
/// The framing `--window` or `--view` asks for (clap refuses both together).
pub(crate) fn framing(&self) -> Framing {
match (self.window, self.view) {
(Some(w), _) => Framing::Window(w),
(None, Some(ViewMode::Extents)) => Framing::Extents,
(None, Some(ViewMode::Current)) => Framing::Current,
(None, None) => Framing::Auto,
}
}
/// The framing flag given, if any.
fn flag(&self) -> Option<&'static str> {
if self.window.is_some() {
Some("--window")
} else if self.view.is_some() {
Some("--view")
} else {
None
}
}
}
/// A framing flag given with an output that isn't an image (the extension decides). `run` without
/// `--export` is clap's `requires` error.
fn framing_error(cli: &Cli) -> Option<UsageError> {
let (framing, out): (&FramingArgs, Option<&Path>) = match &cli.command {
Command::Convert(c) => (&c.framing, Some(&c.output)),
Command::Run(r) => (&r.framing, r.export.as_deref()),
_ => return None,
};
let (flag, out) = (framing.flag()?, out?);
(!cadcraft_io::is_image(out)).then(|| UsageError::FramingNeedsImage { flag, out: out.to_path_buf() })
}
/// An output that is the same file as an input, without `--in-place`. An output that doesn't exist
/// can't be an input, and an I/O error in the comparison is left to the open or write that follows.
fn out_is_input(cli: &Cli) -> Option<UsageError> {
let same = |input: &Path, out: &Path| out.exists() && same_file::is_same_file(input, out).unwrap_or(false);
match &cli.command {
Command::Convert(c) if !c.in_place && same(&c.input, &c.output) => Some(UsageError::OutIsInput(c.output.clone())),
Command::Run(r) if !r.in_place => {
let inputs = r.drawings.iter().filter_map(|d| if let Drawing::File(f) = d { Some(f) } else { None });
let inputs: Vec<&PathBuf> = inputs.collect();
[&r.save, &r.export].into_iter().flatten().find(|out| inputs.iter().any(|i| same(i, out))).map(|out| UsageError::OutIsInput(out.clone()))
}
_ => None,
}
}
/// The backend `cadcraft-cli mcp` serves from.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
}
impl McpArgs {
pub(crate) fn target(&self) -> McpTarget {
match &self.connect {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => McpTarget::Connect { addr: addr.clone(), lazy: self.lazy || self.launch, launch: self.launch },
None => McpTarget::Headless,
}
}
}
/// `mcp --connect`: an empty, flag-like or port-less address is an error, never a silent fall back
/// to a headless session (#103). The host is not resolved here.
fn connect_addr(s: &str) -> Result<String, ValueError> {
let s = s.trim();
let valid = !s.starts_with('-')
&& s.rsplit_once(':').is_some_and(|(host, port)| {
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
!host.is_empty() && port.parse::<u16>().is_ok()
});
if valid { Ok(s.to_string()) } else { Err(ValueError::Connect) }
}
/// The values of `id` with their command-line indices.
fn indexed<'a, T: Clone + Send + Sync + 'static>(m: &'a ArgMatches, id: &str) -> impl Iterator<Item = (usize, &'a T)> {
let at = m.indices_of(id).into_iter().flatten();
let values = m.try_get_many::<T>(id).ok().flatten().into_iter().flatten();
at.zip(values)
}
/// The command-line index of flag `id`, when it was given.
fn flag_index(m: &ArgMatches, id: &str) -> Option<usize> {
if m.value_source(id) == Some(ValueSource::CommandLine) { m.indices_of(id).and_then(Iterator::last) } else { None }
}
/// `run`'s drawings and steps back in command-line order: clap keeps each flag's values apart,
/// their indices say how they interleave.
fn run_order(m: &ArgMatches) -> (Vec<Drawing>, Vec<Step>) {
let mut drawings: Vec<(usize, Drawing)> = indexed(m, "files").map(|(i, f): (_, &PathBuf)| (i, Drawing::File(f.clone()))).collect();
drawings.extend(flag_index(m, "sample").map(|i| (i, Drawing::Sample)));
drawings.extend(flag_index(m, "metric").map(|i| (i, Drawing::Metric)));
drawings.sort_by_key(|d| d.0);
let mut steps: Vec<(usize, Step)> = indexed(m, "script").map(|(i, s): (_, &String)| (i, Step::Script(s.clone()))).collect();
steps.extend(indexed(m, "script_file").map(|(i, s): (_, &PathBuf)| (i, Step::ScriptFile(s.clone()))));
steps.extend(indexed(m, "cmd").map(|(i, c): (_, &CmdStep)| (i, Step::Cmd(c.clone()))));
steps.sort_by_key(|s| s.0);
(drawings.into_iter().map(|d| d.1).collect(), steps.into_iter().map(|s| s.1).collect())
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
/// be valid Unicode, text must. A bare `--help` or `-h` is help even after a mistake on the same
/// line (`run --script --help`): clap reports the mistake first.
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
parse_with(command(|k| std::env::var_os(k)), args)
}
/// The command line. An empty or blank variable counts as unset, except `CADCRAFT_CONFIG_DIR`:
/// empty means no settings folder. `var` looks a variable up.
fn command(var: impl Fn(&OsStr) -> Option<OsString> + Copy) -> clap::Command {
let blank = move |a: clap::Arg| if a.get_id() == "config_dir" { a } else { blank_env_is_unset(a, var) };
Cli::command().mut_args(blank).mut_subcommands(|s| s.mut_args(blank))
}
/// [`parse`] with `cmd` from [`command`]; a bad value from a variable is a usage error naming the
/// variable.
fn parse_with(mut cmd: clap::Command, args: &[OsString]) -> Result<Cli, clap::Error> {
let argv = std::iter::once(OsString::from("cadcraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
// `use_stderr`: a real error, not the help `-h` asked for (a retry would make it `--help`).
Err(e) if e.use_stderr() && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
let help = [OsString::from("cadcraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
}
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
};
let mut cli = Cli::from_arg_matches(&matches)?;
if let (Command::Run(r), Some((_, m))) = (&mut cli.command, matches.subcommand()) {
(r.drawings, r.steps) = run_order(m);
}
if let Some(e) = framing_error(&cli).or_else(|| out_is_input(&cli)) {
if let Some(sub) = matches.subcommand_name().and_then(|n| cmd.find_subcommand_mut(n)) {
return Err(e.into_clap(sub));
}
return Err(e.into_clap(&mut cmd));
}
Ok(cli)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse_args(a: &[&str]) -> Result<Cli, clap::Error> {
parse(&a.iter().map(OsString::from).collect::<Vec<_>>())
}
fn mcp(a: &[&str]) -> Result<McpTarget, clap::Error> {
let args: Vec<&str> = std::iter::once("mcp").chain(a.iter().copied()).collect();
match parse_args(&args)?.command {
Command::Mcp(m) => Ok(m.target()),
_ => panic!("{a:?} is not mcp"),
}
}
/// Every file argument's help names each format of the list it uses.
#[test]
fn help_names_every_format() {
let cli = Cli::command();
let help = |sub: &str, arg: &str| {
let a = cli.find_subcommand(sub).and_then(|c| c.get_arguments().find(|a| a.get_id() == arg)).expect("argument exists");
a.get_help().map(|h| h.to_string()).unwrap_or_default()
};
let cases: [(&str, &str, &[Format]); 8] = [
("info", "file", Format::READ),
("convert", "input", Format::READ),
("convert", "output", Format::WRITE),
("run", "files", Format::READ),
("run", "save", Format::WRITE),
("run", "export", Format::WRITE),
("sample", "output", Format::WRITE),
("convert", "", Format::WRITE),
];
for (sub, arg, list) in cases {
let text = if arg.is_empty() {
cli.find_subcommand(sub).and_then(|c| c.get_about()).map(|a| a.to_string()).unwrap_or_default()
} else {
help(sub, arg)
};
for f in list {
assert!(text.contains(f.extension()), "`{sub} {arg}` help `{text}` lacks {}", f.extension());
}
}
}
#[test]
fn mcp_without_connect_is_headless() {
assert_eq!(mcp(&[]).unwrap(), McpTarget::Headless);
}
#[test]
fn mcp_connect_with_address_selects_remote() {
let to = |addr: &str, lazy, launch| McpTarget::Connect { addr: addr.into(), lazy, launch };
assert_eq!(mcp(&["--connect", "127.0.0.1:7979"]).unwrap(), to("127.0.0.1:7979", false, false));
assert_eq!(mcp(&["--connect", " localhost:7979 "]).unwrap(), to("localhost:7979", false, false));
assert_eq!(mcp(&["--lazy", "--connect", "localhost:7979"]).unwrap(), to("localhost:7979", true, false));
assert_eq!(mcp(&["--connect", "localhost:7979", "--launch"]).unwrap(), to("localhost:7979", true, true));
assert_eq!(mcp(&["--connect", "[::1]:7979"]).unwrap(), to("[::1]:7979", false, false));
// Without an app to reach they mean nothing: never a headless session.
for flag in ["--lazy", "--launch"] {
assert_eq!(mcp(&[flag]).unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument, "{flag}");
}
}
#[test]
fn mcp_connect_without_address_is_an_error_not_headless() {
for bad in [
&["--connect"][..],
&["--connect", ""],
&["--connect", " "],
&["--connect=-v"],
&["--connect", "example.com"],
&["--connect", ":7979"],
&["--connect", "host:99999"],
&["--connect", "[]:1"],
] {
let r = mcp(bad);
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
assert_eq!(e.exit_code(), 2, "{bad:?}");
assert!(e.to_string().contains("--connect <HOST:PORT>"), "{bad:?}: {e}");
}
}
#[test]
fn mcp_rejects_unknown_and_repeated_arguments() {
assert!(mcp(&["--conect", "127.0.0.1:7979"]).is_err());
assert!(mcp(&["127.0.0.1:7979"]).is_err());
// clap names the unknown flag, not the value it left missing.
for flag in ["--foo", "-v"] {
assert_eq!(mcp(&["--connect", flag]).unwrap_err().kind(), clap::error::ErrorKind::UnknownArgument, "{flag}");
}
assert!(mcp(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"]).is_err());
}
#[test]
fn run_keeps_command_line_order() {
let args =
["run", "a.dxf", "--script", "S1", "--metric", "--cmd", "c1", "b.dxf", "--script-file", "f.scr", "--sample", "--cmd", "c2 {\"n\":1}"];
let Command::Run(r) = parse_args(&args).unwrap().command else { panic!("not run") };
let file = |f: &str| Drawing::File(f.into());
let cmd = |id: &str, params| Step::Cmd(CmdStep { id: id.into(), params });
assert_eq!(r.drawings, [file("a.dxf"), Drawing::Metric, file("b.dxf"), Drawing::Sample]);
assert_eq!(r.steps, [Step::Script("S1".into()), cmd("c1", json!({})), Step::ScriptFile("f.scr".into()), cmd("c2", json!({"n": 1}))]);
}
#[test]
fn cmd_values() {
assert_eq!(cmd_step("line").unwrap(), CmdStep { id: "line".into(), params: json!({}) });
assert_eq!(cmd_step("line [1]").unwrap(), CmdStep { id: "line".into(), params: json!([1]) });
for bad in ["", " {}", "line {bad json", "line "] {
assert!(cmd_step(bad).is_err(), "`{bad}` should be rejected");
}
}
#[test]
fn perf_targets() {
let target = |a: &[&str]| -> Option<PerfTarget> {
let args: Vec<&str> = std::iter::once("perf").chain(a.iter().copied()).collect();
let Command::Perf(p) = parse_args(&args).unwrap().command else { panic!("not perf") };
p.target
};
assert_eq!(target(&[]), None);
assert_eq!(target(&["5"]), Some(PerfTarget::Count(5)));
assert_eq!(target(&["0"]), Some(PerfTarget::Count(1)));
assert_eq!(target(&["999999999"]), Some(PerfTarget::Count(MAX_PERF_COUNT)));
assert_eq!(target(&["a.dxf"]), Some(PerfTarget::File("a.dxf".into())));
assert_eq!(target(&["1e3"]), Some(PerfTarget::File("1e3".into())));
}
#[test]
fn framing_options() {
let framing = |out: &str, a: &[&str]| -> Result<Framing, clap::Error> {
let args: Vec<&str> = ["convert", "a.dxf", out].iter().chain(a).copied().collect();
let Command::Convert(c) = parse_args(&args)?.command else { panic!("not convert") };
Ok(c.framing.framing())
};
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
assert_eq!(framing("b.png", &[]).unwrap(), Framing::Auto);
assert_eq!(framing("b.png", &["--view", "extents"]).unwrap(), Framing::Extents);
assert_eq!(framing("b.png", &["--view", "CURRENT"]).unwrap(), Framing::Current);
assert_eq!(framing("b.png", &["--window", "0,0,2,1"]).unwrap(), Framing::Window(b(0.0, 0.0, 2.0, 1.0)));
assert_eq!(framing("b.dxf", &[]).unwrap(), Framing::Auto);
let kind = |out: &str, a: &[&str]| framing(out, a).map(|_| ()).unwrap_err().kind();
assert_eq!(kind("b.png", &["--view", "selection"]), ErrorKind::InvalidValue);
assert_eq!(kind("b.png", &["--window", "0,0,2,1", "--view", "extents"]), ErrorKind::ArgumentConflict);
assert_eq!(kind("b.dxf", &["--view", "extents"]), ErrorKind::ArgumentConflict);
assert_eq!(kind("b.dxf", &["--window", "0,0,2,1"]), ErrorKind::ArgumentConflict);
}
#[test]
fn window_parses() {
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
assert_eq!(parse_window("0,0,30,20").unwrap(), b(0.0, 0.0, 30.0, 20.0));
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 ").unwrap(), b(-5.5, -1000.0, 2000.0, 4.0));
}
#[test]
fn hostile_window_is_an_error() {
for w in [
"",
"1,2,3",
"1,2,3,4,5",
"a,0,1,1",
"NaN,0,1,1",
"0,0,inf,1",
"0,0,1e400,1",
"0,0,0,1",
"0,0,1,0",
"5,0,1,1",
"0,5,1,1",
"-1e300,-1e300,1e300,1e300",
"0,0,1e-12,1e-12",
] {
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
}
}
/// The subcommands that draw or run commands take the settings flags; the others refuse them.
#[test]
fn settings_flags() {
let Command::Convert(c) =
parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p", "--fontalt", "Noto Sans", "--fontfallback="]).unwrap().command
else {
panic!("not convert")
};
assert_eq!(c.settings.config_dir, Some(PathBuf::from("/p")));
assert_eq!((c.settings.fontalt.as_deref(), c.settings.fontfallback.as_deref()), (Some("Noto Sans"), Some("")));
for empty in ["--config-dir=", "--config-dir= "] {
let Command::Run(r) = parse_args(&["run", "--sample", empty]).unwrap().command else { panic!("not run") };
assert_eq!(r.settings.settings_dir(), None, "`{empty}` is no settings folder");
}
let Command::Convert(c) = parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p"]).unwrap().command else { panic!("not convert") };
assert_eq!(c.settings.settings_dir(), Some(PathBuf::from("/p")));
assert!(parse_args(&["sample", "bracket", "b.dxf", "--fontalt", "x"]).is_ok());
assert!(parse_args(&["mcp", "--config-dir", "/p"]).is_ok());
assert!(parse_args(&["info", "a.dxf", "--config-dir", "/p"]).is_err());
}
#[test]
fn blank_variables_count_as_unset_except_the_config_dir() {
let cmd = command(|_| Some("".into()));
for sub in cmd.get_subcommands().filter(|s| s.get_arguments().any(|a| a.get_id() == "fontalt")) {
let env = |id: &str| sub.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env());
assert_eq!((env("fontalt"), env("fontfallback")), (None, None), "{}", sub.get_name());
assert_eq!(env("config_dir"), Some(OsStr::new("CADCRAFT_CONFIG_DIR")), "{}", sub.get_name());
}
}
/// `--fontalt` falls back to `PATH` and rejects every value here, as a bad value from a
/// variable would; tests can't set a variable (`set_var` is unsafe).
#[test]
fn a_bad_value_from_a_variable_names_the_variable() {
// Cargo sets CARGO_PKG_NAME for every test binary, so the variable always has a value.
let reject = clap::builder::PossibleValuesParser::new(["never"]);
let cmd = || Cli::command().mut_subcommand("sample", |s| s.mut_arg("fontalt", |a| a.env("CARGO_PKG_NAME").value_parser(reject.clone())));
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
let text = parse_with(cmd(), &args(&["sample"])).err().map(|e| e.to_string()).unwrap_or_default();
assert!(text.contains("for 'CARGO_PKG_NAME'") && !text.contains("--fontalt"), "{text}");
let text = parse_with(cmd(), &args(&["sample", "--fontalt", "x"])).err().map(|e| e.to_string()).unwrap_or_default();
assert!(text.contains("--fontalt"), "{text}");
}
}
+17
View File
@@ -0,0 +1,17 @@
use super::args::CommandsArgs;
use super::error::Result;
use super::output::Streams;
use cadcraft_engine::Session;
use serde_json::Value;
pub(crate) fn commands(a: &CommandsArgs, io: &mut Streams) -> Result {
let s = Session::new();
let f = a.filter.as_ref().map(|x| x.to_ascii_lowercase());
let v: Vec<Value> = cadcraft_engine::command_specs()
.iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
.map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
.collect();
io.json(&Value::Array(v), a.compact);
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
use super::args::ConvertArgs;
use super::error::Result;
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
use super::{open, unicode};
use cadcraft_engine::Session;
pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
let i = unicode(&a.input)?;
let framing = a.framing.framing();
let mut s = Session::empty();
open(&mut s, &a.input)?;
if let Some(m) = skipped_message(s.doc()?, i) {
io.errln(format_args!("{m}"));
}
if cadcraft_io::is_image(&a.output)
&& let Some(m) = undrawn_message(s.doc()?, i)
{
io.errln(format_args!("{m}"));
}
let window = export_window(&s, framing, None)?;
export_framed(&s, &a.output, window, io)
}
+29
View File
@@ -0,0 +1,29 @@
use crate::output::OutputError;
use crate::perf::PerfError;
use crate::run::RunError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Engine(#[from] cadcraft_engine::EngineError),
#[error(transparent)]
Doc(#[from] cadcraft_engine::doc::DocError),
#[error(transparent)]
Format(#[from] cadcraft_io::IoError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Run(#[from] RunError),
#[error(transparent)]
Output(#[from] OutputError),
#[error(transparent)]
Perf(#[from] PerfError),
/// The engine and io crates take file names as text.
#[error("{}: the file name is not valid Unicode", .0.display())]
NotUnicode(std::path::PathBuf),
#[error("cannot connect to {addr}: {source}")]
McpConnect { addr: String, source: std::io::Error },
}
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
+14
View File
@@ -0,0 +1,14 @@
use super::args::InfoArgs;
use super::error::Result;
use super::open;
use super::output::Streams;
use cadcraft_engine::Session;
use serde_json::json;
pub(crate) fn info(a: &InfoArgs, io: &mut Streams) -> Result {
let mut s = Session::empty();
open(&mut s, &a.file)?;
let v = s.execute("drawing.inspect", &json!({ "entities": false }))?;
io.json(&v, a.compact);
Ok(())
}
+315
View File
@@ -0,0 +1,315 @@
//! `cadcraft-cli`: headless CADCraft. The subcommands and their options are the clap types in
//! `args.rs`; `cadcraft-cli --help` lists them.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ffi::OsString;
use std::io::Write;
use cadcraft_engine::Session;
use serde_json::json;
mod args;
mod commands;
mod convert;
mod error;
mod info;
mod mcp;
mod output;
mod perf;
mod run;
mod sample;
#[cfg(test)]
use args::Framing;
use args::{Command, parse};
use clap::CommandFactory;
use commands::commands;
use convert::convert;
use error::Result;
use info::info;
use mcp::mcp;
use output::Streams;
#[cfg(test)]
use output::{export_window, model_views, write_atomic};
use perf::perf;
use run::run_cmds;
#[cfg(test)]
use run::{fill_last, returned_handle};
use sample::sample;
fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
/// `path` as text, for the engine and io calls that take a file name as a string.
fn unicode(path: &std::path::Path) -> Result<&str> {
path.to_str().ok_or_else(|| error::CliError::NotUnicode(path.to_path_buf()))
}
fn open(s: &mut Session, path: &std::path::Path) -> Result {
s.execute("open", &json!({ "path": unicode(path)? }))?;
Ok(())
}
/// The command line's clap definition, for generating shell completions and man pages.
pub fn command() -> clap::Command {
args::Cli::command()
}
/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok,
/// 1 failure, 2 usage). clap reads the arguments as they are: a value that is not valid Unicode
/// is a usage error, never a panic as with `std::env::args()`.
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
// Help and `--version` (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too,
// for `out`, exit 0: the subcommand never runs.
let cli = match parse(&args) {
Ok(cli) => cli,
Err(e) => {
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
return match write!(to, "{}", e.render()) {
Err(w) if w.kind() != std::io::ErrorKind::BrokenPipe => {
let _ = writeln!(err, "error: can't write the output: {w}");
1
}
_ => e.exit_code(),
};
}
};
// The engine's hooks are set-once, so installing them on every call is harmless.
install_io();
match &cli.command {
Command::Convert(a) => a.settings.apply(),
Command::Run(a) => a.settings.apply(),
Command::Mcp(a) => a.settings.apply(),
Command::Sample(a) => a.settings.apply(),
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
Command::Info(a) => info(a, &mut io),
Command::Convert(a) => convert(a, &mut io),
Command::Run(a) => run_cmds(a, &mut io),
Command::Commands(a) => commands(a, &mut io),
Command::Mcp(a) => mcp(a),
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
Command::Sample(a) => sample(a, &mut io),
};
let mut code = 0;
if let Err(e) = result {
io.errln(format_args!("error: {e}"));
code = 1;
}
io.finish();
if io.stdout_failed() { 1 } else { code }
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
struct FailingWriter(std::io::ErrorKind);
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(self.0.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_failed_write_of_help_exits_1_unless_the_pipe_is_closed() {
let mut err = Vec::new();
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::StorageFull), &mut err), 1);
assert!(String::from_utf8_lossy(&err).starts_with("error: "));
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::BrokenPipe), &mut Vec::new()), 0);
}
#[test]
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
let dir = scratch_dir("replace");
let out = dir.join("a.dxf");
write_atomic(&out, b"old").expect("create");
write_atomic(&out, b"new").expect("overwrite");
assert_eq!(std::fs::read(&out).expect("read"), b"new");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_failure_keeps_destination_and_cleans_up() {
let dir = scratch_dir("fail");
let out = dir.join("target");
std::fs::create_dir(&out).expect("dir as destination");
std::fs::write(out.join("keep"), b"x").expect("seed");
assert!(write_atomic(&out, b"new").is_err());
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
}
fn sample_session() -> Session {
let mut s = Session::empty();
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
s
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
fn assert_near(a: Bounds2, e: Bounds2) {
let d = (a.min - e.min).len().max((a.max - e.max).len());
assert!(d < 1e-9, "{a:?} != {e:?}");
}
#[test]
fn last_placeholder_takes_the_previous_handle() {
let mut s = sample_session();
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let h = returned_handle(&s, &r).expect("LINE returns its handle");
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)).unwrap(), json!({"handle": h.hex()}));
// Nested, as in a selection list.
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)).unwrap(), json!({"handles": [h.hex(), "1"]}));
// Only the exact token: a name or a sentence that merely contains it stays as written.
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
assert_eq!(fill_last(&p, Some(h)).unwrap(), p);
}
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().to_string().contains("{last}"));
}
/// Handles are read from a result by looking the strings up in the drawing: counts and names
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
#[test]
fn returned_handle_reads_only_real_handles() {
let mut s = Session::empty();
s.new_drawing(false);
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
// The most recent of several.
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
}
#[test]
fn default_framing_is_extents() {
// convert: nothing ran, always the extents.
let s = sample_session();
assert_eq!(export_window(&s, Framing::Auto, None).unwrap(), None);
// run: commands that leave the view alone keep the extents framing.
let mut s = sample_session();
let before = model_views(&s);
s.script("LINE 0,0 10,10\n\n").unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
// A fresh drawing too.
let mut s = Session::empty();
s.new_drawing(false);
let before = model_views(&s);
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
}
#[test]
fn zoom_window_frames_export() {
let mut s = sample_session();
let before = model_views(&s);
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
assert_eq!(export_window(&s, Framing::Current, Some(&before)).unwrap(), Some(w));
// --view extents and --window override the zoom.
assert_eq!(export_window(&s, Framing::Extents, Some(&before)).unwrap(), None);
let explicit = b(1.0, 1.0, 2.0, 2.0);
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)).unwrap(), Some(explicit));
// A taller window keeps its height; the 3:2 image adds room left and right.
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
}
#[test]
fn zoom_object_frames_the_selection() {
let mut s = sample_session();
let before = model_views(&s);
// The smallest entity with an area, selected then framed with ZOOM Object.
let d = s.doc().unwrap();
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
let e = d
.model
.iter()
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
.unwrap();
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
s.set_selection(vec![h]);
s.execute("zoom", &json!({"mode": "object"})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
assert!(w.contains(eb.min) && w.contains(eb.max));
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
}
#[test]
fn run_export_honours_zoom() {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let run_svg = |extra: &[&str], out: &str| -> String {
let mut args: Vec<String> = ["run", "--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
args.extend(["--export".to_string(), path(out)]);
assert_eq!(run(args, &mut Vec::new(), &mut Vec::new()), 0);
std::fs::read_to_string(path(out)).unwrap()
};
let base = run_svg(&[], "base.svg");
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
assert_ne!(view_box(&zoomed), view_box(&base));
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
assert_eq!(zoomed, window);
assert_eq!(forced, base);
// Hostile --window: a usage error, nothing written.
let mut err = Vec::new();
assert_eq!(run(["run", "--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")], &mut Vec::new(), &mut err), 2);
assert!(String::from_utf8_lossy(&err).contains("--window"));
assert!(!dir.join("bad.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
use super::args::{McpArgs, McpTarget};
use super::error::{CliError, Result};
pub(crate) fn mcp(a: &McpArgs) -> Result {
let backend: Box<dyn cadcraft_mcp::Backend> = match a.target() {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(source) => return Err(CliError::McpConnect { addr, source }),
},
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock())?;
Ok(())
}
+177
View File
@@ -0,0 +1,177 @@
use super::args::Framing;
use super::error::Result;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{Session, View};
use std::io::Write;
use std::path::{Path, PathBuf};
/// The process's output streams. A write to stdout that fails never stops the program: once
/// stdout has failed, later output is dropped and the work goes on, so the rest of `run` (later
/// `--cmd` results, `--save`, `--export`) still happens and `cadcraft-cli run … --save out.dxf |
/// head` keeps the drawing. A reader that stopped early (a closed pipe, `cadcraft-cli commands |
/// head`) is not an error, as in ripgrep; any other write error is reported once on stderr and
/// makes the exit status 1.
pub(crate) struct Streams<'a> {
out: &'a mut dyn Write,
err: &'a mut dyn Write,
/// Set once a write to `out` has failed; later output is dropped.
gone: bool,
/// Set when `out` failed for a reason other than a closed reader.
failed: bool,
}
impl<'a> Streams<'a> {
pub(crate) fn new(out: &'a mut dyn Write, err: &'a mut dyn Write) -> Self {
Streams { out, err, gone: false, failed: false }
}
/// Write a line to stdout.
pub(crate) fn outln(&mut self, args: std::fmt::Arguments) {
if !self.gone
&& let Err(e) = writeln!(self.out, "{args}")
{
self.out_failed(e);
}
}
/// Write `v` to stdout: pretty-printed, or on one line with `compact` (`--compact`).
pub(crate) fn json(&mut self, v: &serde_json::Value, compact: bool) {
if compact {
self.outln(format_args!("{v}"));
} else {
self.outln(format_args!("{v:#}"));
}
}
/// Write a line to stderr. A failing stderr has nowhere to report to, so it is ignored.
pub(crate) fn errln(&mut self, args: std::fmt::Arguments) {
let _ = writeln!(self.err, "{args}");
}
/// Flush stdout (a pipe that closed during buffered output surfaces here).
pub(crate) fn finish(&mut self) {
if !self.gone
&& let Err(e) = self.out.flush()
{
self.out_failed(e);
}
}
/// Whether stdout failed for a reason other than a closed reader: the exit status is 1.
pub(crate) fn stdout_failed(&self) -> bool {
self.failed
}
fn out_failed(&mut self, e: std::io::Error) {
if std::mem::replace(&mut self.gone, true) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
self.errln(format_args!("cadcraft-cli: can't write to stdout: {e}"));
self.failed = true;
}
}
/// Why writing or framing an output failed.
#[derive(Debug, thiserror::Error)]
pub(crate) enum OutputError {
#[error("--view current: the drawing has no model-space view")]
NoModelView,
#[error("{}: {source}", path.display())]
Write { path: PathBuf, source: std::io::Error },
}
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
pub(crate) fn model_views(s: &Session) -> Vec<(u64, Option<View>)> {
s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect()
}
/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio
/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view).
fn current_window(s: &Session) -> Option<Bounds2> {
let st = s.state().ok()?;
let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?;
let (w, h) = s.viewport_px;
let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5;
Some(Bounds2::new(v.center - half, v.center + half))
}
/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`]
/// taken before the commands ran (`None` for `convert`, where nothing ran).
pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, OutputError> {
match framing {
Framing::Window(w) => Ok(Some(w)),
Framing::Extents => Ok(None),
Framing::Current => current_window(s).map(Some).ok_or(OutputError::NoModelView),
Framing::Auto => {
let Some(before) = before else { return Ok(None) };
let Ok(st) = s.state() else { return Ok(None) };
let now = model_views(s);
let was = before.iter().find(|(uid, _)| *uid == st.uid);
let is = now.iter().find(|(uid, _)| *uid == st.uid);
// Only a view the commands changed counts; a drawing opened by the commands keeps
// the extents framing.
match (was, is) {
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
_ => Ok(None),
}
}
}
}
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
/// leave the file it points to (`--in-place` through a link) unchanged.
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<(), OutputError> {
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
cadcraft_engine::atomic_write::write_atomic(&target, bytes).map_err(|source| OutputError::Write { path: out.to_path_buf(), source })
}
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
export_framed(s, out, None, io)
}
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
let d = s.doc()?;
let bytes = cadcraft_io::write_framed(d, out, window)?;
write_atomic(out, &bytes)?;
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
io.errln(format_args!("{m}"));
}
}
Ok(())
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
+196
View File
@@ -0,0 +1,196 @@
use super::args::PerfTarget;
use super::error::Result;
use super::open;
use super::output::Streams;
use cadcraft_engine::Session;
/// Why `perf` cannot run.
#[derive(Debug, thiserror::Error)]
pub(crate) enum PerfError {
#[error("perf: the drawing is empty")]
EmptyDrawing,
}
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
/// over a square whose side grows with sqrt(n), so density stays constant.
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, cadcraft_engine::doc::DocError> {
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
let mut dr = d::Drawing::new_imperial();
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
for i in 0..n {
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
let s = r.range(0.5, 8.0);
let kind = match i % 5 {
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
3 => EntityKind::LwPolyline(d::LwPolyline {
vertices: (0..6)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
bulge: if k == 2 { 0.4 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 2 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
_ => EntityKind::Text(d::Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.2,
value: format!("T{i}"),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: d::HAlign::Left,
valign: d::VAlign::Baseline,
}),
};
dr.add(&Space::Model, Common::default(), kind)?;
}
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &std::path::Path, io: &mut Streams) -> Result {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state()?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err(PerfError::EmptyDrawing.into());
}
io.outln(format_args!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height()));
io.outln(format_args!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims"));
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
io.outln(format_args!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t)));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
io.outln(format_args!("{:>8} {:>10.1} {n:>10}", "full", ms(t)));
Ok(())
}
pub(crate) fn perf(target: Option<&PerfTarget>, io: &mut Streams) -> Result {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
let n = match target {
Some(PerfTarget::File(path)) => return perf_file(path, io),
Some(PerfTarget::Count(n)) => *n,
None => 200_000,
};
let side = (n as f64).sqrt() * 10.0;
let mut rows: Vec<(String, f64, String)> = Vec::new();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let d = synthetic(n, side)?;
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
let px = side / 1600.0;
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
let t = Instant::now();
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
let ap = 0.1;
let mut r = Rng(42);
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let t = Instant::now();
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
let t = Instant::now();
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
let total = ms(t);
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
// One edit, then a pick: only the touched chunk is re-indexed.
let mut edited = d.clone();
if let Some(h) = edited.model.last().map(|e| e.handle) {
edited.model.modify(h, |e| e.common.layer = "0".into());
}
let t = Instant::now();
let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new()));
let mut sel = 0usize;
let t = Instant::now();
for k in 0..20 {
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
let half = side * 0.05;
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
}
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
let t = Instant::now();
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
let t = Instant::now();
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
let total = ms(t);
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
let t = Instant::now();
let bytes = cadcraft_io::write(&d, "perf.dxf")?;
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
let t = Instant::now();
let back = cadcraft_io::read(&bytes, "perf.dxf")?;
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
io.outln(format_args!("{:<40} {:>12} notes", "operation", "ms"));
io.outln(format_args!("{}", "-".repeat(80)));
for (name, t, note) in rows {
io.outln(format_args!("{name:<40} {t:>12.2} {note}"));
}
Ok(())
}
+223
View File
@@ -0,0 +1,223 @@
use super::args::{CmdStep, Drawing, RunArgs, Step};
use super::error::Result;
use super::open;
use super::output::{Streams, export, export_framed, export_window, model_views};
use cadcraft_engine::Session;
use cadcraft_engine::doc::Handle;
use serde_json::Value;
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
/// through unchanged, and `--script` text is never rewritten.
const LAST_HANDLE: &str = "{last}";
/// Why a `run` step fails.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RunError {
#[error("{}: {source}", .path.display())]
ReadScript { path: std::path::PathBuf, source: std::io::Error },
#[error("--cmd {id}: {source}")]
CmdLast { id: String, source: NoLastHandle },
}
/// A `{last}` placeholder with no handle to fill in.
#[derive(Debug, PartialEq, thiserror::Error)]
#[error("`{{last}}` needs an earlier --cmd in this run to have returned a handle")]
pub(crate) struct NoLastHandle;
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
const RESULT_DEPTH: usize = 16;
/// Result keys whose values are object references: the engine's result convention for handles.
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
fn handle_field(key: &str) -> bool {
matches!(
key,
"handle"
| "handles"
| "viewport"
| "viewports"
| "boundary"
| "clip"
| "insert"
| "members"
| "source"
| "objects"
| "entities"
| "selection"
| "parts"
| "refs"
| "matches"
| "line"
| "arc"
| "blocks"
)
}
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
/// or objects that carry handle fields of their own (`entities`, `parts`).
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::String(t) => {
if let Some(h) = Handle::parse_hex(t)
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
{
out.push(h);
}
}
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// Find handle fields nested in containers that are not handles themselves (`groups` in
/// `groups.list`, whose members are handles): strings here are names, never references.
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// The object handles a command result mentions, in the order it lists them. A handle-only result
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
match v {
Value::String(_) => collect_result_handles(s, v, depth, out),
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
_ => {}
}
}
/// The last handle `r` returned: what a later `{last}` stands for.
pub(crate) fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
let mut hs = Vec::new();
result_handles(s, r, 0, &mut hs);
hs.last().copied()
}
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
/// drawing current.
fn active_drawing(s: &Session) -> Option<u64> {
s.docs.get(s.active).map(|st| st.uid)
}
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
/// error, never a token reaching the command as a (bad) handle.
pub(crate) fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, NoLastHandle> {
match p {
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
Some(h) => Ok(Value::String(h.hex())),
None => Err(NoLastHandle),
},
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
Value::Object(m) => {
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
}
other => Ok(other.clone()),
}
}
/// A step ready to run: script text, or a command.
enum Ready<'a> {
Script(String),
Cmd(&'a CmdStep),
}
pub(crate) fn run_cmds(a: &RunArgs, io: &mut Streams) -> Result {
let framing = a.framing.framing();
let (save, export_to) = (a.save.as_deref(), a.export.as_deref());
let mut s = Session::empty();
for d in &a.drawings {
match d {
Drawing::File(f) => open(&mut s, f)?,
Drawing::Sample => {
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
}
Drawing::Metric => {
s.new_drawing(true);
}
}
}
let mut steps: Vec<Ready> = Vec::new();
for step in &a.steps {
steps.push(match step {
Step::Script(text) => Ready::Script(text.replace("\\n", "\n")),
Step::ScriptFile(p) => Ready::Script(std::fs::read_to_string(p).map_err(|source| RunError::ReadScript { path: p.clone(), source })?),
Step::Cmd(c) => Ready::Cmd(c),
});
}
if s.docs.is_empty() {
s.new_drawing(false);
}
let before = model_views(&s);
let mut last_handle: Option<Handle> = None;
for step in steps {
// A handle belongs to the drawing that returned it: if this step makes another drawing
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
// so it is a handle of the drawing it made current.
let drawing = active_drawing(&s);
match step {
Ready::Script(body) => {
s.script(&body)?;
if active_drawing(&s) != drawing {
last_handle = None;
}
}
Ready::Cmd(CmdStep { id, params }) => {
let params = fill_last(params, last_handle).map_err(|source| RunError::CmdLast { id: id.clone(), source })?;
let r = s.execute(id, &params)?;
if active_drawing(&s) != drawing {
last_handle = None;
}
if let Some(h) = returned_handle(&s, &r) {
last_handle = Some(h);
}
if !r.is_null() {
io.outln(format_args!("{r}"));
}
}
}
}
for l in &s.log {
io.errln(format_args!("{l}"));
}
if let Some(p) = save {
export(&s, p, io)?;
}
if let Some(p) = export_to {
let window = if cadcraft_io::is_image(p) { export_window(&s, framing, Some(&before))? } else { None };
export_framed(&s, p, window, io)?;
}
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
use super::args::{SampleArgs, SampleName};
use super::error::Result;
use super::output::{Streams, write_atomic};
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
let d = match a.name {
SampleName::Bracket => cadcraft_engine::sample::bracket(),
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
};
let bytes = cadcraft_io::write(&d, &a.output)?;
write_atomic(&a.output, &bytes)?;
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
Ok(())
}
+7 -7
View File
@@ -1,23 +1,23 @@
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
//! Bare `cadcraft-cli` is a usage error: the help on stderr, exit 2.
use std::process::Command;
#[test]
fn help_flags_print_usage_to_stdout_and_exit_zero() {
fn help_flags_print_help_to_stdout_and_exit_zero() {
for args in [&["--help"][..], &["-h"], &["help"]] {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
assert!(out.status.success(), "{args:?}: {:?}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}");
assert!(stdout.starts_with("cadcraft-cli: headless CADCraft\n"), "{args:?}: {stdout:?}");
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
}
}
#[test]
fn bare_invocation_still_reports_on_stderr() {
fn bare_invocation_reports_on_stderr() {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
assert_eq!(out.status.code(), Some(2));
assert!(out.stdout.is_empty(), "bare invocation keeps its help on stderr");
assert!(String::from_utf8_lossy(&out.stderr).starts_with("cadcraft-cli: headless CADCraft\n"));
}
+1
View File
@@ -4,3 +4,4 @@ mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
mod usage;
+6 -3
View File
@@ -33,12 +33,15 @@ fn replies(out: &Output) -> Vec<Value> {
#[test]
fn connect_without_address_fails_before_serving() {
let input = format!("{INIT}\n{CIRCLE}\n");
for args in [&["--connect"][..], &["--connect", ""], &["--connect", "--foo"]] {
// clap names an unknown flag rather than the value it left missing.
for (args, named) in
[(&["--connect"][..], "--connect <HOST:PORT>"), (&["--connect", ""], "--connect <HOST:PORT>"), (&["--connect", "--foo"], "'--foo'")]
{
let out = mcp(args, &input);
assert!(!out.status.success(), "{args:?} must exit non-zero");
assert_eq!(out.status.code(), Some(2), "{args:?} is a usage error");
assert!(out.stdout.is_empty(), "{args:?} must not answer MCP requests: {}", String::from_utf8_lossy(&out.stdout));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--connect needs HOST:PORT"), "{args:?}: {err}");
assert!(err.contains(named), "{args:?}: {err}");
}
}
+950
View File
@@ -0,0 +1,950 @@
//! The command line's behaviour: exit codes, streams and messages for help requests, unknown and
//! malformed options, value flags in both spellings, repeats and arity. Every case goes through
//! `invoke`, so the table rows are the record of what each input does. Usage errors are clap's
//! (exit 2, stderr); a subcommand's own failures are `error: …` with exit 1.
use std::path::{Path, PathBuf};
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
Invoke { code, stdout: String::from_utf8_lossy(&out).into_owned(), stderr: String::from_utf8_lossy(&err).into_owned() }
}
/// What a stream holds.
#[derive(Clone, Copy)]
enum Expect {
Empty,
/// The first line equals this.
Line(&'static str),
/// The first line starts with this.
Starts(&'static str),
}
use Expect::{Empty, Line, Starts};
struct Case {
name: &'static str,
args: &'static [&'static str],
code: i32,
stdout: Expect,
stderr: Expect,
}
const fn case(name: &'static str, args: &'static [&'static str], code: i32, stdout: Expect, stderr: Expect) -> Case {
Case { name, args, code, stdout, stderr }
}
/// A scratch directory holding `b.dxf` (the bracket sample) and `s.scr` (a one-line script).
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("cc-cli-usage-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let b = dir.join("b.dxf");
let r = invoke(&["sample", "bracket", b.to_str().expect("utf-8 path")]);
assert_eq!(r.code, 0, "{}", r.stderr);
std::fs::write(dir.join("s.scr"), "LINE 0,0 1,1\n").expect("write script");
Scratch(dir)
}
/// Replaces `{dir}` with the scratch directory and `{b}` with the sample drawing.
fn expand(&self, text: &str) -> String {
let dir = self.0.to_str().expect("utf-8 path");
text.replace("{b}", &Path::new(dir).join("b.dxf").to_string_lossy()).replace("{dir}", dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn check(what: &str, stream: &str, got: &str, expect: Expect, scratch: &Scratch) {
let first = got.lines().next().unwrap_or("");
match expect {
Empty => assert!(got.is_empty(), "{what}: {stream} should be empty, got {got:?}"),
Line(l) => assert_eq!(first, scratch.expand(l), "{what}: {stream}"),
Starts(p) => assert!(first.starts_with(&scratch.expand(p)), "{what}: {stream} should start with {p:?}, got {first:?}"),
}
}
fn run_cases(tag: &str, cases: &[Case]) {
let scratch = Scratch::new(tag);
for c in cases {
let args: Vec<String> = c.args.iter().map(|a| scratch.expand(a)).collect();
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let what = format!("{}: {args:?}", c.name);
let r = invoke(&args);
assert_eq!(r.code, c.code, "{what}: exit code (stderr {:?})", r.stderr);
check(&what, "stdout", &r.stdout, c.stdout, &scratch);
check(&what, "stderr", &r.stderr, c.stderr, &scratch);
}
}
#[test]
fn top_level_requests() {
run_cases(
"top",
&[
case("--help", &["--help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("-h", &["-h"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("help", &["help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
case("--version", &["--version"], 0, Starts("cadcraft-cli "), Empty),
case("-V", &["-V"], 0, Starts("cadcraft-cli "), Empty),
case("version is not a subcommand", &["version"], 2, Empty, Line("error: unrecognized subcommand 'version'")),
case("bare", &[], 2, Empty, Line("cadcraft-cli: headless CADCraft")),
case("unknown subcommand", &["foo"], 2, Empty, Line("error: unrecognized subcommand 'foo'")),
case("unknown top-level flag", &["--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("empty subcommand", &[""], 2, Empty, Line("error: unrecognized subcommand ''")),
],
);
}
/// Each subcommand's help, from `--help`, `-h` or `help <subcommand>`: its first line is the
/// subcommand's summary.
#[test]
fn subcommand_help_requests() {
run_cases(
"subhelp",
&[
case("info --help", &["info", "--help"], 0, Line("Print a drawing's summary as JSON"), Empty),
case("info -h", &["info", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
case(
"convert --help",
&["convert", "--help"],
0,
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
Empty,
),
case(
"convert -h",
&["convert", "-h"],
0,
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
Empty,
),
case(
"run --help",
&["run", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case(
"run -h",
&["run", "-h"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("commands --help", &["commands", "--help"], 0, Line("Print the command catalog as JSON"), Empty),
case(
"mcp --help",
&["mcp", "--help"],
0,
Line("Run the MCP server on stdio: a headless drawing session, or a bridge to the running app"),
Empty,
),
case(
"perf --help",
&["perf", "--help"],
0,
Line(
"Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list build of a drawing, zoomed in step by step",
),
Empty,
),
case("sample --help", &["sample", "--help"], 0, Line("Write a built-in sample drawing"), Empty),
case(
"help run",
&["help", "run"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("help info", &["help", "info"], 0, Line("Print a drawing's summary as JSON"), Empty),
case(
"help after a flag missing its value",
&["run", "--sample", "--script", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case(
"help after an unknown flag",
&["run", "--bogus", "--help"],
0,
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
Empty,
),
case("help after an extra argument", &["info", "{b}", "extra", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
case("--help after -- is a file name", &["run", "--", "--help"], 1, Empty, Starts("error: invalid parameters for `open`: --help:")),
case("run --version", &["run", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
case("info --version", &["info", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
],
);
}
#[test]
fn unknown_flags_on_every_subcommand() {
run_cases(
"unknown",
&[
case("info", &["info", "{b}", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("info, flag first", &["info", "--bogus", "{b}"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("info, short flag", &["info", "-x"], 2, Empty, Line("error: unexpected argument '-x' found")),
case("info, a file name starting with - after --", &["info", "--", "-x"], 1, Empty, Starts("error: invalid parameters for `open`: -x:")),
case("convert", &["convert", "{b}", "{dir}/o.svg", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("run", &["run", "--sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("run, flag first", &["run", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("commands", &["commands", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("commands -- ends the options", &["commands", "--"], 0, Line("["), Empty),
case("mcp", &["mcp", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("mcp positional", &["mcp", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
case("perf", &["perf", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("sample", &["sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
case("sample after name", &["sample", "bracket", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
],
);
}
/// `--x=v` is a spelling of `--x v`, and the one a value starting with `-` needs.
#[test]
fn value_flags_in_both_forms() {
run_cases(
"forms",
&[
case("--window v", &["convert", "{b}", "{dir}/w1.svg", "--window", "0,0,100,100"], 0, Empty, Starts("wrote {dir}/w1.svg (")),
case("--window=v", &["convert", "{b}", "{dir}/w2.svg", "--window=0,0,100,100"], 0, Empty, Starts("wrote {dir}/w2.svg (")),
case(
"run --window v",
&["run", "--sample", "--window", "0,0,100,100", "--export", "{dir}/w3.svg"],
0,
Empty,
Starts("wrote {dir}/w3.svg ("),
),
case(
"run --window=v",
&["run", "--sample", "--window=0,0,100,100", "--export", "{dir}/w4.svg"],
0,
Empty,
Starts("wrote {dir}/w4.svg ("),
),
case("--view v", &["convert", "{b}", "{dir}/v1.svg", "--view", "extents"], 0, Empty, Starts("wrote {dir}/v1.svg (")),
case("--view=v", &["convert", "{b}", "{dir}/v2.svg", "--view=extents"], 0, Empty, Starts("wrote {dir}/v2.svg (")),
case("run --view v", &["run", "--sample", "--view", "current", "--export", "{dir}/v3.svg"], 0, Empty, Starts("wrote {dir}/v3.svg (")),
case("run --view=v", &["run", "--sample", "--view=extents", "--export={dir}/v4.svg"], 0, Empty, Starts("wrote {dir}/v4.svg (")),
case("--script v", &["run", "--sample", "--script", "LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
case("--script=v", &["run", "--sample", "--script=LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
case("--script-file v", &["run", "--sample", "--script-file", "{dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
case("--script-file=v", &["run", "--sample", "--script-file={dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
case("--cmd v", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case("--cmd=v", &["run", "--sample", "--cmd=nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case("--cmd=-v", &["run", "--sample", "--cmd=-x"], 1, Empty, Line("error: unknown command `-x`")),
case("--save v", &["run", "--sample", "--save", "{dir}/s1.dxf"], 0, Empty, Starts("wrote {dir}/s1.dxf (")),
case("--save=v", &["run", "--sample", "--save={dir}/s2.dxf"], 0, Empty, Starts("wrote {dir}/s2.dxf (")),
case("--export v", &["run", "--sample", "--export", "{dir}/e1.svg"], 0, Empty, Starts("wrote {dir}/e1.svg (")),
case("--export=v", &["run", "--sample", "--export={dir}/e2.svg"], 0, Empty, Starts("wrote {dir}/e2.svg (")),
case(
"--window=-v: negative coordinates need =",
&["convert", "{b}", "{dir}/w5.svg", "--window=-1000,-1000,1000,1000"],
0,
Empty,
Starts("wrote {dir}/w5.svg ("),
),
case(
"--window -v: a value starting with - is a flag",
&["convert", "{b}", "{dir}/w6.svg", "--window", "-1000,-1000,1000,1000"],
2,
Empty,
Line("error: unexpected argument '-1' found"),
),
case("--connect v", &["mcp", "--connect", "127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
case("--connect=v", &["mcp", "--connect=127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
],
);
}
/// A value flag at the end of the arguments, or followed by another flag, is missing its value:
/// a flag is never taken as a value.
#[test]
fn flags_missing_their_value() {
run_cases(
"missing",
&[
case(
"convert --window last",
&["convert", "{b}", "{dir}/o.svg", "--window"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"convert --view last",
&["convert", "{b}", "{dir}/o.svg", "--view"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"convert --window then flag",
&["convert", "{b}", "{dir}/o.svg", "--window", "--view", "extents"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"convert --view then flag",
&["convert", "{b}", "{dir}/o.svg", "--view", "--window", "0,0,1,1"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"run --window last",
&["run", "--sample", "--window"],
2,
Empty,
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
),
case(
"run --view last",
&["run", "--sample", "--view"],
2,
Empty,
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
),
case(
"run --script last",
&["run", "--sample", "--script"],
2,
Empty,
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
),
case(
"run --script then flag",
&["run", "--sample", "--script", "--sample"],
2,
Empty,
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
),
case(
"run --script-file last",
&["run", "--script-file"],
2,
Empty,
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
),
case(
"run --script-file then flag",
&["run", "--sample", "--script-file", "--sample"],
2,
Empty,
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
),
case(
"run --cmd last",
&["run", "--sample", "--cmd"],
2,
Empty,
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
),
case(
"run --cmd then flag",
&["run", "--sample", "--cmd", "--sample"],
2,
Empty,
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
),
case(
"run --save last",
&["run", "--sample", "--save"],
2,
Empty,
Line("error: a value is required for '--save <OUT.dxf>' but none was supplied"),
),
case(
"run --export last",
&["run", "--sample", "--export"],
2,
Empty,
Line("error: a value is required for '--export <OUT.png>' but none was supplied"),
),
case(
"mcp --connect last",
&["mcp", "--connect"],
2,
Empty,
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
),
case(
"mcp --connect then flag",
&["mcp", "--connect", "--lazy"],
2,
Empty,
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
),
case(
"mcp --connect empty",
&["mcp", "--connect", " "],
2,
Empty,
Line("error: invalid value ' ' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
),
case(
"mcp --connect without a port",
&["mcp", "--connect", "example.com"],
2,
Empty,
Line(
"error: invalid value 'example.com' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
),
),
case(
"mcp --connect port out of range",
&["mcp", "--connect", "host:99999"],
2,
Empty,
Line(
"error: invalid value 'host:99999' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
),
),
case(
"mcp --connect=-v",
&["mcp", "--connect=-v"],
2,
Empty,
Line("error: invalid value '-v' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
),
],
);
}
#[test]
fn repeated_and_combined_flags() {
run_cases(
"repeat",
&[
case(
"--save twice",
&["run", "--sample", "--save", "{dir}/a.dxf", "--save", "{dir}/b2.dxf"],
2,
Empty,
Line("error: the argument '--save <OUT.dxf>' cannot be used multiple times"),
),
case(
"--export twice",
&["run", "--sample", "--export", "{dir}/x1.svg", "--export", "{dir}/x2.svg"],
2,
Empty,
Line("error: the argument '--export <OUT.png>' cannot be used multiple times"),
),
case(
"--window twice",
&["run", "--sample", "--window", "0,0,1,1", "--window", "0,0,2,2", "--export", "{dir}/ww.svg"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
),
case(
"convert --window twice",
&["convert", "{b}", "{dir}/cw.svg", "--window", "0,0,1,1", "--window", "0,0,2,2"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
),
case(
"--view twice, different",
&["run", "--sample", "--view", "extents", "--view", "current", "--export", "{dir}/vv.svg"],
2,
Empty,
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
),
case(
"--view twice, same",
&["run", "--sample", "--view", "extents", "--view", "extents", "--export", "{dir}/vs.svg"],
2,
Empty,
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
),
case("--sample twice", &["run", "--sample", "--sample"], 2, Empty, Line("error: the argument '--sample' cannot be used multiple times")),
case("--metric twice", &["run", "--metric", "--metric"], 2, Empty, Line("error: the argument '--metric' cannot be used multiple times")),
case(
"--lazy twice",
&["mcp", "--connect", "127.0.0.1:1", "--lazy", "--lazy"],
2,
Empty,
Line("error: the argument '--lazy' cannot be used multiple times"),
),
case(
"--launch twice",
&["mcp", "--connect", "127.0.0.1:1", "--launch", "--launch"],
2,
Empty,
Line("error: the argument '--launch' cannot be used multiple times"),
),
case(
"--script, --script-file and --cmd repeat",
&[
"run",
"--sample",
"--script",
"LINE 0,0 1,1",
"--script",
"LINE 0,0 2,2",
"--script-file",
"{dir}/s.scr",
"--script-file",
"{dir}/s.scr",
],
0,
Empty,
Starts("Command: LINE"),
),
case(
"--window and --view",
&["run", "--sample", "--window", "0,0,1,1", "--view", "extents", "--export", "{dir}/wv.svg"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
),
case(
"convert --window and --view",
&["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"],
2,
Empty,
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
),
case(
"--view is case-insensitive",
&["run", "--sample", "--view", "EXTENTS", "--export", "{dir}/vu.svg"],
0,
Empty,
Starts("wrote {dir}/vu.svg ("),
),
case(
"--connect twice",
&["mcp", "--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"],
2,
Empty,
Line("error: the argument '--connect <HOST:PORT>' cannot be used multiple times"),
),
case("--lazy without --connect", &["mcp", "--lazy"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("--launch without --connect", &["mcp", "--launch"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("--sample --metric: both accepted", &["run", "--sample", "--metric"], 0, Empty, Empty),
case("--sample and a file", &["run", "--sample", "{b}"], 0, Empty, Empty),
case("two files", &["run", "{b}", "{b}"], 0, Empty, Empty),
case(
"--window without export",
&["run", "--sample", "--window", "0,0,1,1"],
2,
Empty,
Line("error: the following required arguments were not provided:"),
),
case(
"--view without export",
&["run", "--sample", "--view", "current"],
2,
Empty,
Line("error: the following required arguments were not provided:"),
),
case(
"--window with a dxf output",
&["convert", "{b}", "{dir}/o.dxf", "--window", "0,0,1,1"],
2,
Empty,
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"--view with a dxf output",
&["convert", "{b}", "{dir}/o.dxf", "--view", "extents"],
2,
Empty,
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"run --window with a dxf export",
&["run", "--sample", "--window", "0,0,1,1", "--export", "{dir}/o.dxf"],
2,
Empty,
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
case(
"run --view with a dxf export",
&["run", "--sample", "--view", "current", "--export", "{dir}/o.dxf"],
2,
Empty,
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
),
],
);
}
#[test]
fn arity_and_positional_arguments() {
run_cases(
"arity",
&[
case("info without a file", &["info"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("info with two files", &["info", "{b}", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
case("info missing file", &["info", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("convert with no files", &["convert"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("convert with one file", &["convert", "{b}"], 2, Empty, Line("error: the following required arguments were not provided:")),
case(
"convert with three files",
&["convert", "{b}", "{dir}/o.svg", "{dir}/x.svg"],
2,
Empty,
Line("error: unexpected argument '{dir}/x.svg' found"),
),
case("run missing file", &["run", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("commands, no match", &["commands", "zzzznomatch"], 0, Line("[]"), Empty),
case(
"commands with an extra argument",
&["commands", "zzzznomatch", "extra"],
2,
Empty,
Line("error: unexpected argument 'extra' found"),
),
case("sample without arguments", &["sample"], 2, Empty, Line("error: the following required arguments were not provided:")),
case("sample without an output", &["sample", "bracket"], 2, Empty, Line("error: the following required arguments were not provided:")),
case(
"sample with a bad name",
&["sample", "nosuch", "{dir}/o.dxf"],
2,
Empty,
Line("error: invalid value 'nosuch' for '<bracket|floorplan>'"),
),
case(
"sample names are case-sensitive",
&["sample", "Bracket", "{dir}/o.dxf"],
2,
Empty,
Line("error: invalid value 'Bracket' for '<bracket|floorplan>'"),
),
case(
"sample with an empty output",
&["sample", "bracket", ""],
2,
Empty,
Line("error: a value is required for '<OUT>' but none was supplied"),
),
case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '<FILE>' but none was supplied")),
case("perf with an empty file name", &["perf", ""], 2, Empty, Line("error: a value is required for '[N|FILE]' but none was supplied")),
case(
"sample with an extra argument",
&["sample", "bracket", "{dir}/sx.dxf", "extra"],
2,
Empty,
Line("error: unexpected argument 'extra' found"),
),
case("sample floor alias", &["sample", "floor", "{dir}/sf.dxf"], 0, Empty, Starts("wrote {dir}/sf.dxf (")),
case("sample with a bad extension", &["sample", "bracket", "{dir}/o.xyz"], 1, Empty, Line("error: unsupported file type `xyz`")),
case("perf with a word", &["perf", "abc"], 1, Empty, Starts("error: invalid parameters for `open`: abc:")),
case("perf with a negative number", &["perf", "-5"], 2, Empty, Line("error: unexpected argument '-5' found")),
case("perf with a missing file", &["perf", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
case("perf 1", &["perf", "1"], 0, Starts("operation"), Empty),
case("perf 0 is raised to 1", &["perf", "0"], 0, Starts("operation"), Empty),
case("perf with two counts", &["perf", "1", "2"], 2, Empty, Line("error: unexpected argument '2' found")),
],
);
}
#[test]
fn bad_values() {
run_cases(
"values",
&[
case(
"--view sideways",
&["convert", "{b}", "{dir}/o.svg", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"run --view sideways",
&["run", "--sample", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"--view sideways before the output check",
&["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"],
2,
Empty,
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
),
case(
"--window 3 numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"],
2,
Empty,
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"--window 5 numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3,4,5"],
2,
Empty,
Line("error: invalid value '1,2,3,4,5' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"--window reversed",
&["convert", "{b}", "{dir}/o.svg", "--window", "3,3,1,1"],
2,
Empty,
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
),
case(
"--window not numbers",
&["convert", "{b}", "{dir}/o.svg", "--window", "a,b,c,d"],
2,
Empty,
Line("error: invalid value 'a,b,c,d' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `a`"),
),
case(
"--window NaN",
&["convert", "{b}", "{dir}/o.svg", "--window", "NaN,0,1,1"],
2,
Empty,
Line("error: invalid value 'NaN,0,1,1' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `NaN`"),
),
case(
"--window too large",
&["convert", "{b}", "{dir}/o.svg", "--window", "0,0,1e13,1"],
2,
Empty,
Line("error: invalid value '0,0,1e13,1' for '--window <x0,y0,x1,y1>': export window: coordinates must lie within ±1e12"),
),
case(
"run --window 3 numbers",
&["run", "--sample", "--window", "1,2,3", "--export", "{dir}/o.svg"],
2,
Empty,
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
),
case(
"run --window reversed",
&["run", "--sample", "--window", "3,3,1,1", "--export", "{dir}/o.svg"],
2,
Empty,
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
),
case(
"--cmd bad json",
&["run", "--sample", "--cmd", "line {bad json"],
2,
Empty,
Line("error: invalid value 'line {bad json' for '--cmd <id {json}>': key must be a string at line 1 column 2"),
),
case(
"--cmd bad json after a good step: nothing runs",
&["run", "--sample", "--cmd", "line {\"points\":[[0,0],[1,1]]}", "--cmd", "line {"],
2,
Empty,
Starts("error: invalid value 'line {' for '--cmd <id {json}>': "),
),
case(
"--cmd without an id",
&["run", "--sample", "--cmd", " {}"],
2,
Empty,
Line("error: invalid value ' {}' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
),
case(
"--cmd empty",
&["run", "--sample", "--cmd="],
2,
Empty,
Line("error: invalid value '' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
),
case("--cmd unknown id", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
case(
"--cmd {last} with no earlier handle",
&["run", "--sample", "--cmd", "erase {\"handles\":[\"{last}\"]}"],
1,
Empty,
Line("error: --cmd erase: `{last}` needs an earlier --cmd in this run to have returned a handle"),
),
case("--script-file missing", &["run", "--sample", "--script-file", "missing.scr"], 1, Empty, Starts("error: missing.scr:")),
],
);
}
/// `mcp` serves on the process's real stdin and stdout, so the headless server runs through the
/// binary, with a closed stdin.
#[test]
fn mcp_headless_with_a_closed_stdin() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
.arg("mcp")
.stdin(std::process::Stdio::null())
.output()
.expect("run cadcraft-cli");
assert_eq!(out.status.code(), Some(0));
assert!(out.stdout.is_empty() && out.stderr.is_empty());
}
/// A drawing to open whose name isn't valid Unicode reaches the handler as a path; the engine opens
/// files by text name, so it is refused there (exit 1), naming the file. Text that isn't Unicode is
/// a usage error.
#[cfg(unix)]
#[test]
fn non_unicode_arguments() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let invoke_os = |args: Vec<OsString>| {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
assert!(out.is_empty());
(code, String::from_utf8_lossy(&err).into_owned())
};
let latin1 = || OsString::from_vec(b"caf\xe9.dxf".to_vec());
let (code, err) = invoke_os(vec![OsString::from("info"), latin1()]);
assert_eq!(code, 1);
assert_eq!(err.lines().next(), Some("error: caf\u{FFFD}.dxf: the file name is not valid Unicode"));
let (code, err) = invoke_os(vec![OsString::from("run"), OsString::from("--sample"), OsString::from("--cmd"), latin1()]);
assert_eq!(code, 2);
assert!(err.starts_with("error: invalid UTF-8 was detected"), "{err}");
}
/// An output name that isn't valid Unicode is written as given: only its extension picks the format.
#[cfg(unix)]
#[test]
fn non_unicode_output_names() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let s = Scratch::new("nonunicode-out");
let latin1 = |ext: &str| {
let mut n = b"caf\xe9.".to_vec();
n.extend_from_slice(ext.as_bytes());
s.0.join(OsString::from_vec(n))
};
let run = |args: Vec<OsString>| {
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = cadcraft_cli::run(args, &mut out, &mut err);
(code, String::from_utf8_lossy(&err).into_owned())
};
let b = s.0.join("b.dxf");
let cases: [(Vec<OsString>, std::path::PathBuf); 3] = [
(vec!["sample".into(), "bracket".into(), latin1("dxf").into()], latin1("dxf")),
(vec!["convert".into(), b.clone().into(), latin1("svg").into()], latin1("svg")),
(vec!["run".into(), "--sample".into(), "--save".into(), latin1("dwg").into()], latin1("dwg")),
];
for (args, path) in cases {
let (code, err) = run(args.clone());
assert_eq!(code, 0, "{args:?}: {err}");
assert!(path.exists(), "{path:?}");
assert!(std::fs::metadata(&path).expect("stat output").len() > 0, "{path:?}");
}
// An input with such a name is still refused.
let (code, err) = run(vec!["info".into(), latin1("dxf").into()]);
assert_eq!(code, 1);
assert!(err.contains("not valid Unicode"), "{err}");
}
/// An output that is an input file is refused before any work (exit 2, the input untouched) unless
/// `--in-place` allows it; the same file under another spelling counts, another existing file doesn't.
#[test]
fn output_that_replaces_an_input() {
let s = Scratch::new("inplace");
let dir = s.0.to_str().expect("utf-8 path").to_string();
let b = format!("{dir}/b.dxf");
let original = std::fs::read(&b).expect("read input");
std::fs::create_dir_all(format!("{dir}/sub")).expect("create sub");
let dotted = format!("{dir}/sub/../b.dxf");
#[allow(unused_mut)]
let mut spellings = vec![b.clone(), dotted];
#[cfg(unix)]
{
let link = format!("{dir}/link.dxf");
std::os::unix::fs::symlink(&b, &link).expect("symlink");
spellings.push(link);
}
for out in &spellings {
for args in [vec!["convert", &b, out], vec!["run", &b, "--save", out], vec!["run", &b, "--export", out]] {
let r = invoke(&args);
assert_eq!(r.code, 2, "{args:?}: {}", r.stderr);
assert!(r.stdout.is_empty(), "{args:?}");
assert!(r.stderr.starts_with(&format!("error: {out} is an input file")), "{args:?}: {}", r.stderr);
assert!(r.stderr.contains("--in-place"), "{args:?}: {}", r.stderr);
assert_eq!(std::fs::read(&b).expect("read input"), original, "{args:?}: input changed");
}
}
// Another existing file is replaced as before.
let other = format!("{dir}/other.dxf");
std::fs::write(&other, "x").expect("write other");
let r = invoke(&["convert", &b, &other]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_ne!(std::fs::read(&other).expect("read other"), b"x");
// --in-place allows it, for each spelling and flag: the input itself is rewritten, and a
// symlink stays a link to it.
let line = "line {\"points\":[[0,0],[1,1]]}";
let lines = |p: &str| std::fs::read_to_string(p).expect("read input").lines().filter(|l| l.trim_end() == "LINE").count();
let before = lines(&b);
for out in &spellings {
std::fs::write(&b, &original).expect("restore input");
let r = invoke(&["convert", &b, out, "--in-place"]);
assert_eq!(r.code, 0, "convert {out}: {}", r.stderr);
assert_eq!(lines(&b), before, "convert {out} keeps the drawing");
for flag in ["--save", "--export"] {
std::fs::write(&b, &original).expect("restore input");
let r = invoke(&["run", &b, "--cmd", line, "--in-place", flag, out]);
assert_eq!(r.code, 0, "{flag} {out}: {}", r.stderr);
assert_eq!(lines(&b), before + 1, "{flag} {out}: the input gains the line");
}
}
#[cfg(unix)]
{
let link = format!("{dir}/link.dxf");
assert!(std::fs::symlink_metadata(&link).expect("stat link").file_type().is_symlink(), "{link} is still a symlink");
}
// An output that doesn't exist yet is never an input.
let new = format!("{dir}/new.dxf");
let r = invoke(&["run", &b, "--save", &new]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert!(std::path::Path::new(&new).is_file(), "{new} written");
}
/// JSON results are pretty-printed; `--compact` puts each on one line (RFC-0012 rule 1.3). `run`
/// prints one line per `--cmd` result either way.
#[test]
fn json_output_is_pretty_unless_compact() {
let s = Scratch::new("compact");
let b = format!("{}/b.dxf", s.0.to_str().expect("utf-8 path"));
for args in [&["info", b.as_str()][..], &["commands", "ellipse"]] {
let help = invoke(&[args[0], "--help"]);
assert!(
help.stdout.contains("--compact") && help.stdout.contains("One line of JSON instead of pretty-printed"),
"{} --help: {}",
args[0],
help.stdout
);
let pretty = invoke(args);
assert_eq!(pretty.code, 0, "{args:?}: {}", pretty.stderr);
assert!(pretty.stdout.lines().count() > 1, "{args:?} is pretty-printed: {}", pretty.stdout);
let compact = invoke(&[args, &["--compact"]].concat());
assert_eq!(compact.code, 0, "{args:?} --compact: {}", compact.stderr);
assert_eq!(compact.stdout.lines().count(), 1, "{args:?} --compact is one line: {}", compact.stdout);
let a: serde_json::Value = serde_json::from_str(&pretty.stdout).expect("pretty JSON");
let c: serde_json::Value = serde_json::from_str(&compact.stdout).expect("compact JSON");
assert_eq!(a, c, "{args:?}: same JSON either way");
}
let r = invoke(&["run", "--sample", "--cmd", "drawing.inspect", "--cmd", "drawing.inspect"]);
assert_eq!(r.code, 0, "{}", r.stderr);
assert_eq!(r.stdout.lines().count(), 2, "one line per --cmd result: {}", r.stdout);
assert_eq!(invoke(&["run", "--sample", "--compact"]).code, 2, "run takes no --compact");
}
@@ -0,0 +1,30 @@
//! The clap definition is consistent, and `examples/man-and-completions.rs` writes every shell's
//! completions and every man page under the names `packaging/linux/package.sh` installs.
// The example itself, so the test runs its code; its `main` is unused here.
#[allow(dead_code)]
#[path = "../examples/man-and-completions.rs"]
mod example;
#[test]
fn command_is_consistent() {
cadcraft_cli::command().debug_assert();
}
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("man-and-completions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
example::generate(&dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("cadcraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
[lints]
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+2 -1
View File
@@ -95,7 +95,8 @@ impl Bridge {
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
+26 -6
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;
@@ -41,33 +44,45 @@ mod web {
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx);
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
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
}
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
@@ -152,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);
}
}
+2 -1
View File
@@ -68,8 +68,9 @@ impl Inbox {
}
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
pub fn take_dropped(&self, ctx: &egui::Context) {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
if let Some(file) = f.web_file() {
self.read(file.clone());
}
+10 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
default-run = "cadcraft"
@@ -16,10 +17,12 @@ cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
@@ -33,10 +36,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
craft-update = { workspace = true }
rfd = { workspace = true }
sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
# controls and under floating panels.
craft-window = { workspace = true }
muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
+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() {
+172
View File
@@ -0,0 +1,172 @@
//! The `CADCRAFT_*` variables of the desktop app, read once at startup into [`Env`] and passed on
//! from there; no library crate reads them. `--help` lists them from [`VARS`]. `--control`'s
//! fallback, `CADCRAFT_CONTROL_PORT`, is clap's (see `launch_args`).
use std::ffi::OsString;
use std::fmt::Write as _;
use std::path::PathBuf;
use cadcraft_ui_egui::switch::Switch;
const CONFIG_DIR: &str = "CADCRAFT_CONFIG_DIR";
const LOCALE: &str = "CADCRAFT_LOCALE";
const VSYNC: &str = "CADCRAFT_VSYNC";
const WAYLAND: &str = "CADCRAFT_WAYLAND";
const NO_NATIVE_MENU: &str = "CADCRAFT_NO_NATIVE_MENU";
const SYSTEM_CURSOR: &str = "CADCRAFT_SYSTEM_CURSOR";
const FONTALT: &str = "CADCRAFT_FONTALT";
const FONTFALLBACK: &str = "CADCRAFT_FONTFALLBACK";
/// Every variable [`Env`] reads, with its `--help` line.
pub(crate) const VARS: &[(&str, &str)] = &[
(CONFIG_DIR, "the settings folder (logs, autosave, workspace, plotters); empty means no settings folder"),
(LOCALE, "the interface language, instead of the system's"),
(VSYNC, "1 presents with vsync, 0 without it (default: without on Linux and BSD)"),
(WAYLAND, "1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
(NO_NATIVE_MENU, "macOS: 1 keeps the menus inside the window; 0 or unset is off"),
(SYSTEM_CURSOR, "1 uses the system cursor instead of the drawn crosshair; 0 or unset is off"),
(FONTALT, "the font that replaces a missing one"),
(FONTFALLBACK, "the fonts tried, in order, for characters a font lacks"),
];
/// The process environment, read once at startup. A blank variable counts as unset, except
/// `CADCRAFT_CONFIG_DIR`.
#[derive(Debug, PartialEq)]
pub(crate) struct Env {
/// The settings folder (logs, autosave, workspace, plotters): `CADCRAFT_CONFIG_DIR`; else, when
/// that is unset, the platform's (`cadcraft_engine::settings_dir::platform`). `None`: no
/// folder, because the variable is empty or blank (the way to run without one) or the
/// platform has none.
pub(crate) config_dir: Option<PathBuf>,
/// The interface language, instead of the system's.
pub(crate) locale: Option<String>,
/// Present with vsync (default: off on Linux and BSD, on elsewhere).
pub(crate) vsync: Switch,
/// Keep the native Wayland backend instead of XWayland.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub(crate) wayland: Switch,
/// macOS: keep the menus inside the window.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) no_native_menu: Switch,
/// Use the system cursor instead of the drawn crosshair.
pub(crate) system_cursor: Switch,
/// FONTALT at startup.
pub(crate) font_alt: Option<String>,
/// FONTFALLBACK at startup.
pub(crate) font_fallback: Option<String>,
}
impl Env {
pub(crate) fn read() -> Env {
Env::from_vars(|k| std::env::var_os(k))
}
/// [`Env::read`] with `var` looking a variable up.
pub(crate) fn from_vars(var: impl Fn(&str) -> Option<OsString>) -> Env {
let text = |k: &str| var(k).and_then(|v| v.into_string().ok()).filter(|v| !v.trim().is_empty());
let switch = |k: &str| Switch::parse(k, var(k).map(|v| v.to_string_lossy().into_owned()).as_deref());
Env {
config_dir: match var(CONFIG_DIR) {
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
Some(dir) => Some(PathBuf::from(dir)),
None => cadcraft_engine::settings_dir::platform(&var),
},
locale: text(LOCALE),
vsync: switch(VSYNC),
wayland: switch(WAYLAND),
no_native_menu: switch(NO_NATIVE_MENU),
system_cursor: switch(SYSTEM_CURSOR),
font_alt: text(FONTALT),
font_fallback: text(FONTFALLBACK),
}
}
}
/// The `--help` section that lists [`VARS`].
pub(crate) fn help() -> String {
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0) + 3;
VARS.iter().fold(String::from("Environment:"), |mut help, (name, line)| {
let _ = write!(help, "\n {name:<width$}{line}");
help
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
}
#[test]
fn every_variable_read_is_in_the_help_and_every_listed_one_is_read() {
let asked = RefCell::new(Vec::new());
Env::from_vars(|k| {
asked.borrow_mut().push(k.to_owned());
None
});
let ours: Vec<String> = asked.into_inner().into_iter().filter(|k| k.starts_with("CADCRAFT_")).collect();
let help = help();
let unset = Env::from_vars(|_| None);
for k in &ours {
assert!(VARS.iter().any(|(name, _)| name == k), "{k} is read but not in VARS");
}
for (name, line) in VARS {
assert!(ours.iter().any(|k| k == name), "{name} is in VARS but not read");
assert_ne!(Env::from_vars(env(&[(name, "1")])), unset, "{name} sets no field");
assert!(help.lines().any(|l| l.trim_start().starts_with(name) && l.ends_with(line)), "{help}");
}
}
#[test]
fn values_are_read_and_blank_ones_are_unset() {
let vars = [
(CONFIG_DIR, "/private/cc"),
("HOME", "/home/u"),
("XDG_CONFIG_HOME", "/x"),
("APPDATA", "C:\\a"),
(LOCALE, "cs-CZ"),
(VSYNC, " On "),
(WAYLAND, "0"),
(NO_NATIVE_MENU, "yes"),
(SYSTEM_CURSOR, "maybe"),
(FONTALT, "Noto Sans"),
(FONTFALLBACK, "Vazirmatn, msjh.ttc"),
];
let read = Env::from_vars(env(&vars));
assert_eq!(
read,
Env {
config_dir: Some(PathBuf::from("/private/cc")),
locale: Some("cs-CZ".into()),
vsync: Switch::On,
wayland: Switch::Off,
no_native_menu: Switch::On,
system_cursor: Switch::Default,
font_alt: Some("Noto Sans".into()),
font_fallback: Some("Vazirmatn, msjh.ttc".into()),
}
);
let blank = vars.map(|(k, _)| (k, if k == FONTALT { " " } else { "" }));
let read = Env::from_vars(env(&blank));
assert_eq!(read.config_dir, None, "an empty variable means no directory, not the working directory");
assert_eq!((read.locale, read.font_alt, read.font_fallback), (None, None, None));
assert_eq!([read.vsync, read.wayland, read.no_native_menu, read.system_cursor], [Switch::Default; 4]);
}
#[test]
fn config_dir_unset_is_the_platform_folder_set_is_that_folder_blank_is_none() {
let platform = [("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
let expected = cadcraft_engine::settings_dir::platform(env(&platform));
assert!(expected.is_some());
assert_eq!(Env::from_vars(env(&platform)).config_dir, expected);
let set = [platform.as_slice(), &[(CONFIG_DIR, "/private/cc")]].concat();
assert_eq!(Env::from_vars(env(&set)).config_dir, Some(PathBuf::from("/private/cc")));
for blank in ["", " "] {
let vars = [platform.as_slice(), &[(CONFIG_DIR, blank)]].concat();
assert_eq!(Env::from_vars(env(&vars)).config_dir, None, "{blank:?} means no settings folder, not the platform's");
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! The desktop app's command line, as clap types: `cadcraft --help` comes from these doc comments
//! and its Environment section from [`crate::env::VARS`], so the help and the parsers can't disagree. Anything they don't declare is a usage error naming
//! it (exit status 2), so a typo such as `--sampel` doesn't open the window and then fail to open
//! the flag as a file; so is a flag without its value, a repeated flag and a bad port, from the
//! flag or from `CADCRAFT_CONTROL_PORT`. A value never starts with `-`, and a file whose name
//! starts with `-` goes after `--`.
use std::ffi::OsString;
use std::path::PathBuf;
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
use clap::{Command, CommandFactory, FromArgMatches, Parser};
/// Opens CADCraft with the given files
#[derive(Parser)]
#[command(name = "cadcraft", version = cadcraft_engine::build_version(), after_help = crate::env::help())]
pub(crate) struct LaunchArgs {
/// Files to open. Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start the localhost JSON-lines control server on this port
// allow_negative_numbers makes `--control -1` a bad value rather than a missing one.
#[arg(long, value_name = "PORT", env = "CADCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
pub(crate) control: Option<u16>,
/// Open with the sample drawing
#[arg(long)]
pub(crate) sample: bool,
}
/// The command line. An empty or blank variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command().mut_args(|a| blank_env_is_unset(a, |k| std::env::var_os(k)))
}
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("cadcraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
LaunchArgs::from_arg_matches(&matches)
}
/// A control port, for clap's `value_parser`: `--control` and `CADCRAFT_CONTROL_PORT`, which may
/// carry spaces.
fn control_port(s: &str) -> Result<u16, NotAPort> {
s.trim().parse().map_err(|_| NotAPort)
}
/// A [`control_port`] value that isn't a port.
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("expected a port, 0-65535")]
struct NotAPort;
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variables, so one exported where the tests run can't change them.
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn values_are_parsed() {
let parsed = parse_args(&["--control", " 50494 ", "--sample", "a.dxf", "b.dwg"]).unwrap();
assert_eq!((parsed.control, parsed.sample), (Some(50494), true));
assert_eq!(parsed.files, [PathBuf::from("a.dxf"), PathBuf::from("b.dwg")]);
let parsed = parse_args(&[]).unwrap();
assert_eq!((parsed.control, parsed.sample, parsed.files.len()), (None, false, 0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
for bad in ["78787", "-1", "abc", "99999999999999999999"] {
assert!(usage_error(&["--control", bad]).contains(&format!("invalid value '{bad}' for '--control <PORT>'")), "{bad}");
}
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
}
#[test]
fn usage_errors_exit_2() {
assert!(usage_error(&["--sampel"]).contains("unexpected argument '--sampel'"));
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
// A following flag is never taken as the value.
assert!(usage_error(&["--control", "--sample"]).contains("a value is required for '--control <PORT>'"));
assert!(usage_error(&["--sample", "--sample"]).contains("cannot be used multiple times"));
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
}
#[test]
fn version_stops_parsing() {
let e = parse_args(&["-V", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("{}\n", crate::version_string()));
let e = parse_args(&["--version", "--bogus"]).err().unwrap();
assert_eq!(e.kind(), ErrorKind::DisplayVersion);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let e = parse_args(&["-h", "--sampel", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
let help = parse_args(&["--help"]).err().unwrap().to_string();
assert!(help.contains("--control <PORT>") && help.contains("CADCRAFT_CONFIG_DIR"), "{help}");
assert!(usage_error(&["--sampel", "--", "--help"]).contains("unexpected argument '--sampel'"));
let parsed = parse_args(&["a.dxf", "--", "--help", "-x"]).unwrap();
assert_eq!(parsed.files, ["a.dxf", "--help", "-x"].map(PathBuf::from));
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
let parsed = parse_args(&["-psn_0_12345", "a.dxf"]).unwrap();
assert_eq!(parsed.files, [PathBuf::from("a.dxf")]);
let parsed = parse_args(&["--", "-psn_x.dxf"]).unwrap();
assert_eq!(parsed.files, [PathBuf::from("-psn_x.dxf")]);
}
/// A name that isn't valid Unicode is kept as given.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_kept() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.dxf".to_vec());
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone(), OsString::from("b.dxf")]).unwrap();
assert_eq!(parsed.files, [PathBuf::from(&latin1), PathBuf::from("b.dxf")]);
}
/// `--control` falls back to `PATH` here instead of `CADCRAFT_CONTROL_PORT`: `PATH` is set
/// wherever the tests run and is never a port, and tests can't set a variable (`set_var` is
/// unsafe).
#[test]
fn the_flag_wins_over_its_variable_and_a_bad_value_names_the_variable() {
let Some(path) = std::env::var_os("PATH").filter(|p| !p.is_empty()) else { return };
let path_fallback = || command().mut_args(|a| a.env(None::<&str>)).mut_arg("control", |a| a.env("PATH"));
let e = parse(path_fallback(), []).err().unwrap();
assert_eq!(e.exit_code(), 2);
let text = e.to_string();
assert!(text.contains(&format!("invalid value '{}' for 'PATH'", path.to_string_lossy())), "{text}");
assert!(!text.contains("--control <PORT>'"), "{text}");
assert_eq!(parse(path_fallback(), ["--control", "7"].map(OsString::from)).unwrap().control, Some(7));
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app
//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/cadcraft.log`
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`).
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; empty means none; see `env.rs`).
//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a
//! run that crashed survives the next launch.
//!
+86 -150
View File
@@ -1,6 +1,6 @@
//! CADCraft desktop app.
//!
//! Usage: `cadcraft [--control <port>] [--sample] [files…]`
//! Usage: `cadcraft [--control <port>] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`])
//!
//! `--control <port>` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`.
@@ -10,14 +10,16 @@
//! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`.
//!
//! `log` records go to standard error and `<settings dir>/logs/cadcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`] and [`config_dir`].
//! levels); see [`logging`]. The `CADCRAFT_*` variables are read once, at startup; see [`env`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod desktop_theme;
mod env;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod launch_args;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -26,6 +28,7 @@ mod system_animations;
mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
@@ -39,11 +42,25 @@ struct App {
workspace_error: Option<String>,
#[cfg(target_os = "macos")]
native_menu: Option<native_menu::NativeMenu>,
/// `CADCRAFT_NO_NATIVE_MENU`: the menus stay inside the window.
#[cfg(target_os = "macos")]
menus_in_window: bool,
/// The `movable` flag last set on the main window.
#[cfg(target_os = "macos")]
movable: Option<bool>,
}
impl App {
fn persist_workspace(&mut self, final_attempt: bool) {
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
/// `ctx` is `None` only for the final attempt at exit.
fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
let result = match ctx {
None => self.workspace.save_on_exit(&self.cad.ui),
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
if let Some(wait) = wait {
ctx.request_repaint_after(wait);
}
}),
};
if let Err(error) = result {
if self.workspace_error.as_ref() != Some(&error) {
eprintln!("CADCraft workspace: {error}");
@@ -65,37 +82,49 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
}
impl eframe::App for App {
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && !self.menus_in_window {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(Some(ctx));
}
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
self.cad.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
self.persist_workspace(false);
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
}
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(true);
self.persist_workspace(None);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -165,30 +194,6 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters
}
/// The per-user settings directory (it holds `logs/` and `autosave/`): `CADCRAFT_CONFIG_DIR` if set, else
/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs,
/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows.
fn config_dir() -> Option<std::path::PathBuf> {
config_dir_from(|k| std::env::var_os(k))
}
/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable
/// counts as unset, so a blank value never means the working directory.
fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
if let Some(dir) = set("CADCRAFT_CONFIG_DIR") {
return Some(dir);
}
if cfg!(target_os = "macos") {
set("HOME").map(|h| h.join("Library/Application Support/CADCraft"))
} else if cfg!(windows) {
set("APPDATA").map(|a| a.join("CADCraft"))
} else {
// The XDG spec ignores a relative XDG_CONFIG_HOME.
set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft"))
}
}
const APP_ID: &str = "ai.storyteller.cadcraft";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
@@ -204,68 +209,49 @@ fn app_icon() -> Option<egui::IconData> {
}
fn version_string() -> String {
let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev");
format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION"))
}
/// The control port from `--control <port>` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is
/// not a port is logged and ignored instead of silently starting no control channel.
fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
let value = value?;
let port = value.trim().parse().ok();
if port.is_none() {
log::warn!("{source}: {value:?} is not a port number; the control channel is off");
}
port
}
/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already
/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off.
fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
if value.is_some() {
return control_port_from("--control", value);
}
match current {
Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"),
None => log::warn!("--control: no port given; the control channel is off"),
}
current
format!("cadcraft {}", cadcraft_engine::build_version())
}
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let mut control_port =
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = control_flag(control_port, args.next()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("{}", version_string());
return Ok(());
}
_ => files.push(a),
// 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(&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);
}
return Ok(());
}
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown or
// repeated flag, a missing or bad value, a bad CADCRAFT_CONTROL_PORT) names the argument on
// standard error and exits 2 before the window opens.
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
let vars = env::Env::read();
let (control_port, sample, files) = (launch.control, launch.sample, launch.files);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
match vars.config_dir.as_ref().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()),
Some(Err(e)) => log::warn!("no log file: {e}"),
None => {
logger.stderr_only();
log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)");
log::warn!("no log file: no settings directory (an empty CADCRAFT_CONFIG_DIR turns it off)");
}
}
}
install_io();
let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos"))));
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
if let Some(name) = &vars.font_alt {
cadcraft_engine::fonts::ttf::set_font_alt(name);
}
if let Some(list) = &vars.font_fallback {
cadcraft_engine::fonts::ttf::set_fallback_fonts(list);
}
let surface = cadcraft_ui_egui::gpu::surface_config(vars.vsync, cfg!(all(unix, not(target_os = "macos"))));
let mut options = eframe::NativeOptions {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
@@ -280,6 +266,9 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
@@ -305,7 +294,7 @@ fn main() -> eframe::Result {
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() {
if std::env::var_os("DISPLAY").is_some() && vars.wayland != Switch::On {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -316,15 +305,16 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services());
let (workspace, restored, workspace_error) = workspace::Workspace::load();
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
if let Some(ui) = restored {
app.ui = ui;
}
app.set_system_cursor_default(vars.system_cursor == Switch::On);
if let Some(error) = &workspace_error {
eprintln!("CADCraft workspace: {error}");
app.set_status(error);
}
app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect());
app.system_languages = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect());
// The user profile (Options): drafting settings, selection sizes, drawing-area colours.
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
app.load_profile(&profile);
@@ -336,8 +326,9 @@ fn main() -> eframe::Result {
cadcraft_ui_egui::autosave::load_prefs(&prefs);
}
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = config_dir() {
if let Some(dir) = &vars.config_dir {
app.enable_autosave(&dir.join("autosave"));
app.enable_update_check();
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in.
let support = dir.join("support");
@@ -381,6 +372,10 @@ fn main() -> eframe::Result {
workspace_error,
#[cfg(target_os = "macos")]
native_menu: None,
#[cfg(target_os = "macos")]
menus_in_window: vars.no_native_menu == Switch::On,
#[cfg(target_os = "macos")]
movable: None,
}))
}),
)
@@ -388,13 +383,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop};
use std::ffi::OsString;
use std::path::PathBuf;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
}
use super::{filters_for_save, follow_desktop};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -412,59 +401,6 @@ mod tests {
}
}
#[test]
fn control_port_values_that_are_not_ports_are_ignored() {
assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979));
assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979));
assert_eq!(control_port_from("--control", None), None);
for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] {
assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}");
}
}
#[test]
fn control_without_a_port_keeps_the_environment_port() {
// `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844)
assert_eq!(control_flag(Some(47321), None), Some(47321));
assert_eq!(control_flag(None, None), None);
// A port given on the command line still wins over the variable.
assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979));
assert_eq!(control_flag(Some(47321), Some("abc".into())), None);
}
#[test]
fn the_override_variable_wins_and_empty_values_are_unset() {
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc")));
let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")];
assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory");
assert_eq!(config_dir_from(env(&[])), None);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn linux_and_bsd_follow_xdg_config_home() {
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft")));
assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
// The XDG spec says a relative XDG_CONFIG_HOME is ignored.
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_uses_application_support() {
assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft")));
}
#[cfg(windows)]
#[test]
fn windows_uses_appdata() {
assert_eq!(
config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])),
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
);
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
+53 -6
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::mpsc;
use cadcraft_ui_egui::menus::{self, Entry};
use cadcraft_ui_egui::{CadApp, i18n};
@@ -15,10 +16,14 @@ pub struct NativeMenu {
submenus: Vec<(String, Submenu)>,
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
/// ~300 ms of menu enablement.
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
counter: usize,
events: mpsc::Receiver<MenuEvent>,
}
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -30,7 +35,16 @@ fn accel(sc: &str) -> Option<Accelerator> {
}
impl NativeMenu {
pub fn install(app: &mut CadApp) -> Self {
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
// wake the app for it: the handler does, and the choice runs in the next frame.
let (sender, events) = mpsc::channel();
let wake = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.send(event);
wake.request_repaint();
}));
let menu = Menu::new();
let mut items = HashMap::new();
let language = app.language();
@@ -66,7 +80,17 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
Self {
_menu: menu,
items,
submenus,
predefined,
language,
last_refresh: f64::NEG_INFINITY,
recent: app.ui.recent_files.clone(),
counter,
events,
}
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -87,13 +111,14 @@ impl NativeMenu {
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
}
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
while let Ok(ev) = self.events.try_recv() {
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
} else {
menus::activate(app, &cmd.clone());
menus::activate_native(app, ctx, &cmd.clone());
}
ctx.request_repaint();
}
@@ -113,17 +138,25 @@ impl NativeMenu {
}
}
let now = cadcraft_ui_egui::now_ms();
if now - self.last_refresh < 300.0 {
if !refresh_due(now, self.last_refresh) {
return;
}
self.last_refresh = now;
let typing = menus::text_field_window(ctx).is_some();
for (cmd, _, item) in self.items.values() {
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
item.set_enabled(en);
}
}
}
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build(
parent: &Submenu,
entries: &[Entry],
@@ -150,3 +183,17 @@ fn build(
}
}
}
#[cfg(test)]
mod tests {
use super::refresh_due;
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+99 -70
View File
@@ -1,15 +1,18 @@
//! Native workspace preferences. Drawing data and transient dialogs never enter this file.
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use cadcraft_ui_egui::UiState;
use serde_json::{Value, json};
const MAX_BYTES: u64 = 64 * 1024;
/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are
/// spaced at least this far apart, and the newest state is written when the interval ends.
const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500);
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub struct Workspace {
@@ -17,18 +20,7 @@ pub struct Workspace {
last_attempt: Option<Value>,
read_failed: bool,
pending_write_error: Option<String>,
}
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = get("CADCRAFT_CONFIG_DIR") {
return (!root.is_empty()).then(|| PathBuf::from(root));
}
let nonempty = |key| get(key).filter(|value| !value.is_empty()).map(PathBuf::from);
match platform {
"macos" => nonempty("HOME").map(|home| home.join("Library/Application Support/CADCraft")),
"windows" => nonempty("APPDATA").map(|root| root.join("CADCraft")),
_ => nonempty("XDG_CONFIG_HOME").map(|root| root.join("cadcraft")).or_else(|| nonempty("HOME").map(|home| home.join(".config/cadcraft"))),
}
last_write: Option<Instant>,
}
fn snapshot(ui: &UiState) -> Result<Value, String> {
@@ -44,6 +36,7 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
}
Ok(json!({
"docking": ui.docking,
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
"showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes,
"showToolbar": ui.show_toolbar,
@@ -80,10 +73,16 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
// Dock schemas can evolve independently of the other saved workspace preferences.
// Recover only this optional field; runtime UiState deserialization remains strict.
// A layout from before the current shape (split frames, saved without `dockingVersion`)
// gives way to the default, its hidden panels with it.
let mut fields = workspace.clone();
let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking"));
let (docking, version) = match fields.as_object_mut() {
Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
None => (None, None),
};
let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
if let Some(docking) = docking
if let Some(docking) = docking.filter(|_| current)
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
{
@@ -127,8 +126,9 @@ fn write(path: &Path, workspace: &Value) -> Result<(), String> {
}
impl Workspace {
pub fn load() -> (Self, Option<UiState>, Option<String>) {
Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json")))
/// The workspace file in the settings directory `config_dir`; without one nothing is kept.
pub fn load(config_dir: Option<&Path>) -> (Self, Option<UiState>, Option<String>) {
Self::at(config_dir.map(|root| root.join("workspace.json")))
}
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
@@ -136,44 +136,54 @@ impl Workspace {
match result {
Ok(ui) => {
let initial = ui.as_ref().cloned().unwrap_or_default();
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None)
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
}
Err(error) => (
Self { path, last_attempt: None, read_failed: true, pending_write_error: None },
Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
None,
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
),
}
}
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
let Some(path) = &self.path else { return Ok(()) };
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
/// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
let Some(path) = &self.path else { return Ok(None) };
if self.read_failed {
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
return Ok(());
return Ok(None);
}
let value = snapshot(ui)?;
if self.last_attempt.as_ref() == Some(&value) {
return self.pending_write_error.clone().map_or(Ok(()), Err);
return self.pending_write_error.clone().map_or(Ok(None), Err);
}
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
&& !wait.is_zero()
{
return Ok(Some(wait));
}
// Keep a failed state's warning without retrying disk I/O every rendered frame.
self.last_attempt = Some(value.clone());
self.last_write = Some(now);
let result = write(path, &value);
self.pending_write_error = result.as_ref().err().cloned();
result
result.map(|()| None)
}
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
if self.pending_write_error.is_some() {
self.last_attempt = None;
}
self.save_if_changed(ui)
self.last_write = None;
self.save_if_changed(ui, Instant::now()).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn temp_dir() -> PathBuf {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
@@ -184,20 +194,18 @@ mod tests {
#[test]
fn profile_override_isolated_or_explicitly_disabled() {
let get = |key: &str| match key {
"CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")),
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
_ => None,
let vars = |config: Option<&'static str>| {
move |key: &str| match key {
"CADCRAFT_CONFIG_DIR" => config.map(OsString::from),
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
_ => None,
}
};
for platform in ["macos", "windows", "linux", "freebsd"] {
assert_eq!(config_dir_from(platform, get), Some(PathBuf::from("/private/test-state")));
assert_eq!(config_dir_from(platform, |key| if key == "CADCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { get(key) }), None);
}
assert_eq!(
config_dir_from("linux", |key| (key == "XDG_CONFIG_HOME").then(|| OsString::from("/config"))),
Some(PathBuf::from("/config/cadcraft"))
);
assert_eq!(config_dir_from("macos", |_| None), None);
let dir = |config| crate::env::Env::from_vars(vars(config)).config_dir;
assert_eq!(dir(Some("/private/test-state")), Some(PathBuf::from("/private/test-state")));
assert_eq!(dir(Some("")), None);
let (_, restored, error) = Workspace::load(dir(Some("")).as_deref());
assert!(restored.is_none() && error.is_none(), "no settings folder, no workspace file");
}
#[test]
@@ -216,7 +224,7 @@ mod tests {
start_tab: true,
..Default::default()
};
first.save_if_changed(&ui).unwrap();
first.save_if_changed(&ui, Instant::now()).unwrap();
let bytes = fs::read(&path).unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(value["workspace"].get("dialog").is_none());
@@ -229,37 +237,42 @@ mod tests {
let mut transient_only = ui.clone();
transient_only.dialog = None;
transient_only.start_tab = false;
first.save_if_changed(&transient_only).unwrap();
first.save_if_changed(&transient_only, Instant::now()).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::{Panel, permissions};
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState::default();
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
ui.docking = app.ui.docking;
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
ui.show_toolsets = false;
store.save_if_changed(&ui).unwrap();
store.save_if_changed(&ui, Instant::now()).unwrap();
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
let (_, reloaded, error) = Workspace::at(Some(path));
assert!(error.is_none());
let mut reloaded = reloaded.unwrap();
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
assert!(!reloaded.show_toolsets);
reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn version_one_docking_file_still_loads() {
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
use cadcraft_ui_egui::docking::default_workspace;
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
let file = r#"{"version":1,"workspace":{
@@ -271,28 +284,18 @@ mod tests {
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
let directory = temp_dir();
let path = directory.join("workspace.json");
fs::write(&path, file).unwrap();
let (_, restored, error) = Workspace::at(Some(path));
assert!(error.is_none(), "{error:?}");
let mut ui = restored.unwrap();
let ui = restored.unwrap();
assert_eq!(ui.toolset_tab, "Modeling");
let docking = &ui.docking;
assert_eq!(docking.layout.floating.len(), 1);
let window = &docking.layout.floating[0];
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
assert_eq!(
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
);
assert!(!docking.layout.contains(&Panel::ToolSets));
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(tools.anchor, Some(Panel::Canvas));
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
assert_eq!(ui.history_lines, 4);
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
assert_eq!(ui.docking.layout, default_workspace().layout);
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
fs::remove_dir_all(directory).unwrap();
}
@@ -311,7 +314,7 @@ mod tests {
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
assert!(ui.is_none());
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap();
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes);
}
fs::remove_dir_all(directory).unwrap();
@@ -326,7 +329,8 @@ mod tests {
json!({"layout":false,"hidden":[]}),
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
] {
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking});
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
let (_, restored, error) = Workspace::at(Some(path.clone()));
@@ -342,16 +346,41 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let start = Instant::now();
let mut ui = UiState { history_lines: 1, ..Default::default() };
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
// A drag: a new state every frame, none of them written until the interval ends.
for (frame, lines) in (2..=5).enumerate() {
ui.history_lines = lines;
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
let wait = store.save_if_changed(&ui, now).unwrap();
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
}
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
ui.history_lines = 6;
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
store.save_on_exit(&ui).unwrap();
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn invalid_update_cannot_replace_last_good_workspace() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState { show_palettes: false, ..Default::default() };
store.save_if_changed(&ui).unwrap();
store.save_if_changed(&ui, Instant::now()).unwrap();
let good = fs::read(&path).unwrap();
ui.collapsed_groups = vec!["x".repeat(257)];
assert!(store.save_if_changed(&ui).is_err());
assert!(store.save_if_changed(&ui, Instant::now()).is_err());
assert_eq!(fs::read(&path).unwrap(), good);
fs::remove_dir_all(directory).unwrap();
}
@@ -364,9 +393,9 @@ mod tests {
// Simulate a destination becoming unavailable after startup.
fs::create_dir(&path).unwrap();
let ui = UiState { show_palettes: false, ..Default::default() };
let first_error = store.save_if_changed(&ui).unwrap_err();
let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
fs::remove_dir(&path).unwrap();
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error);
assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
assert!(!path.exists(), "ordinary frames must not retry disk writes");
store.save_on_exit(&ui).unwrap();
assert!(!read(&path).unwrap().unwrap().show_palettes);
+4
View File
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+10 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -91,3 +88,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();
+1 -1
View File
@@ -235,7 +235,7 @@ mod budget_tests {
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+195 -44
View File
@@ -81,6 +81,12 @@ pub struct SolveOptions {
pub keep: Vec<Handle>,
/// Entities that should take up the change.
pub prefer_move: Vec<Handle>,
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
/// just put down): see [`edited_points`].
pub hold: Vec<GeomRef>,
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
/// now: see [`moved_anchors`].
pub anchors: Vec<(u32, Vec2)>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
@@ -205,12 +211,19 @@ pub(crate) struct Comp {
pub(crate) model: Model,
pub(crate) built: Vec<Built>,
weights: Vec<f64>,
/// Parameters of the points held by [`SolveOptions::hold`].
held: Vec<usize>,
tol: f64,
}
impl Comp {
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
let mut locked = vec![false; self.model.x0.len()];
for &j in &self.held {
if let Some(l) = locked.get_mut(j) {
*l = true;
}
}
for (i, b) in self.built.iter().enumerate() {
if Some(i) == skip || b.form != Form::Lock {
continue;
@@ -245,7 +258,30 @@ impl Comp {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
// an axis end by index does not.
if o.converged && !self.model.storable(&o.x) {
let mut out = Vec::new();
match self.model.stored_form(&o.x) {
Some(mut xc) => {
// Contact parameters are positions along the curves: they follow the stored form.
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
for (k, r) in b.refs.iter().enumerate() {
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
*slot = self.model.stored_t(&o.x, r.handle, t);
}
}
}
self.eval(&xc, &mut out, skip);
o.converged = lm::max_abs(&out) <= self.tol;
o.x = xc;
}
None => o.converged = false,
}
}
Ok(o)
}
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
@@ -322,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
groups.into_values().collect()
}
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
/// less, angles and ratios by size², and contact parameters move freely.
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
for (j, f) in slot_scales(model, s) {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
weights
}
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
/// points is what is minimised.
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
}
}
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
@@ -346,55 +432,24 @@ pub(crate) fn build_budgeted(
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
match interpret(c, &mut model, target) {
Ok(b) => built.push(b),
Ok(mut b) => {
if let Form::Anchor(_) = b.form
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
{
b.form = Form::Anchor(a);
}
built.push(b);
}
Err(msg) => skipped.push((c.id, msg)),
}
}
if built.is_empty() {
continue;
}
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
// Angles and ratios: weight by size² so the movement of points is what is minimised.
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
let scaled: Vec<(usize, f64)> = match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
};
for (j, f) in scaled {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
let weights = solver_weights(&model, opts);
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, tol });
comps.push(Comp { model, built, weights, held, tol });
}
Ok((comps, skipped))
}
@@ -445,6 +500,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
/// the point back.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d
.constraints
@@ -461,10 +519,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let mut changed = false;
for r in &refs {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
if !same_shape {
continue;
}
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
&& translated(&now, &old, r.handle)
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
{
for (v, &w) in dst.iter_mut().zip(src) {
*v = w;
}
changed = true;
}
continue;
};
for (i, j) in i.into_iter().zip(j) {
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
&& *slot != v
@@ -480,6 +549,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
changed
}
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
m.point_refs(h)
.iter()
.filter_map(|r| m.lock_indices(r))
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
.collect()
}
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
let tol = scale_tol(xb) * 10.0;
let pts: Vec<(usize, usize)> =
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
return false;
}
(0..xb.len())
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
}
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
/// object kept its points or moved them all.
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
return Vec::new();
}
let tol = scale_tol(&old.x0) * 10.0;
let refs = now.point_refs(h);
let moved: Vec<GeomRef> = refs
.iter()
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
.copied()
.collect();
if moved.len() == refs.len() { Vec::new() } else { moved }
}
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
///
/// A Fix on a derived point anchors it where it is when the system is built, and
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
/// would leave the Fix satisfied at the new place.
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
let fixes: Vec<(u32, GeomRef)> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.filter_map(|c| match c.refs.as_slice() {
[r] => Some((c.id, *r)),
_ => None,
})
.collect();
if fixes.is_empty() {
return Vec::new();
}
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let tol = scale_tol(&old.x0);
let mut out = Vec::new();
for (id, r) in fixes {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
if !same_shape || now.lock_indices(&r).is_some() {
continue;
}
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
&& !p.near(a, tol)
{
out.push((id, a));
}
}
out
}
/// True when every interpretable constraint holds at the current geometry.
pub fn all_satisfied(d: &Drawing) -> bool {
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
+140 -67
View File
@@ -32,6 +32,8 @@ pub const MAX_RIGID_POINTS: usize = 256;
const MAX_RIGID_MEMBERS: usize = 100_000;
/// Shared across every model built for one analysis request.
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
const RATIO_TOL: f64 = 1e-9;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
@@ -189,6 +191,77 @@ impl Model {
Self::build_budgeted(d, handles, &mut members_left)
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
return None;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
return None;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => return None,
};
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
@@ -198,71 +271,7 @@ impl Model {
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let shape = match &e.kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
continue;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
continue;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => continue,
};
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
@@ -477,6 +486,18 @@ impl Model {
}
}
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
let Some(s) = self.slot(h) else { return Vec::new() };
let at = |sub: Sub| GeomRef { handle: h, sub };
match s.shape {
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
Shape::Point => vec![at(Sub::Whole)],
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
}
}
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
/// point (fixed through a residual instead).
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
@@ -504,6 +525,57 @@ impl Model {
Some(v)
}
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
/// longer one: a solution past that is stored with its axes swapped or its minor axis
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
pub fn storable(&self, x: &[f64]) -> bool {
self.slots.iter().all(|s| match s.shape {
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
_ => true,
})
}
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
/// a negative ratio or an ellipse a zero one.
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
let mut xc = x.to_vec();
for s in &self.slots {
let Shape::Ellipse { arc } = s.shape else { continue };
let o = s.off;
let mut ratio = *x.get(o + 4)?;
if ratio < 0.0 && !arc {
ratio = -ratio;
}
if ratio <= 0.0 {
return None;
}
if ratio > 1.0 + RATIO_TOL {
let major = p2(x, o + 2)?.perp() * ratio;
*xc.get_mut(o + 2)? = major.x;
*xc.get_mut(o + 3)? = major.y;
ratio = 1.0 / ratio;
if arc {
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
}
}
*xc.get_mut(o + 4)? = ratio;
}
Some(xc)
}
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
/// the axes were swapped.
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
let Some(s) = self.slot(h) else { return t };
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
}
/// Write solved parameters back. Returns the handles that moved.
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
let mut moved = Vec::new();
@@ -572,8 +644,9 @@ impl Model {
[a, b] if arc => (*a, *b),
_ => (el.start, el.end),
};
// Keep the stored form: the major axis is the longer one.
if ratio > 1.0 {
// Keep the stored form: the major axis is the longer one. `storable` lets a
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
if ratio > 1.0 + RATIO_TOL {
major = major.perp() * ratio;
ratio = 1.0 / ratio;
if arc {
+341 -185
View File
@@ -3,7 +3,7 @@
use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef};
use cadcraft_geom::Vec2;
use crate::curves::circle_at;
use crate::curves::{Ell, circle_at};
use crate::model::Model;
/// Contact parameter samples per curve when guessing where two curves touch.
@@ -205,42 +205,7 @@ impl Built {
};
b.rows = 1;
}
K::Tangent | K::Smooth => {
if n != 2 {
return need("two objects");
}
if c.refs.iter().any(|r| m.is_spline(r)) {
return Self::spline_form(b, m, x);
}
b.rows = 1;
if let (Some((a, bb)), Some((cc, _))) = (ln(0), ci(1)) {
b.form = Form::LC { swap: false };
b.sign = sgn(dir_n(a, bb).cross(cc - a));
} else if let (Some((a, bb)), Some((cc, _))) = (ln(1), ci(0)) {
b.form = Form::LC { swap: true };
b.sign = sgn(dir_n(a, bb).cross(cc - a));
} else if let (Some((c1, r1)), Some((c2, r2))) = (ci(0), ci(1)) {
b.form = Form::CC;
// sign > 0: external tangency; < 0: internal.
let d = c1.dist(c2);
b.sign = if d >= r1.abs().max(r2.abs()) { 1.0 } else { -1.0 };
b.target = sgn(r1 - r2);
} else if let (Some((a, bb)), Some(e)) = (ln(0), el(1)) {
b.form = Form::LE { swap: false };
b.sign = sgn(dir_n(a, bb).cross(e.c - a));
} else if let (Some((a, bb)), Some(e)) = (ln(1), el(0)) {
b.form = Form::LE { swap: true };
b.sign = sgn(dir_n(a, bb).cross(e.c - a));
} else if (ci(0).is_some() || el(0).is_some()) && (ci(1).is_some() || el(1).is_some()) {
b.form = Form::Contact;
b.rows = 3;
b.aux_init = b.contact_guess(m, x).ok_or_else(|| format!("{} could not find where the curves touch", c.kind.name()))?;
} else if ln(0).is_some() && ln(1).is_some() && c.kind == K::Smooth {
b.form = Form::LL;
} else {
return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve");
}
}
K::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x),
K::Symmetric => {
if n != 3 || ln(2).is_none() {
return need("two objects and a symmetry line");
@@ -347,154 +312,8 @@ impl Built {
out.resize(start + self.rows, 0.0);
}
fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec<f64>) -> Option<()> {
use ConstraintKind as K;
let r = |i: usize| self.refs.get(i);
let pt = |i: usize| m.point(x, r(i)?);
let ln = |i: usize| m.line(x, r(i)?);
let ci = |i: usize| m.circle(x, r(i)?);
let el = |i: usize| m.ellipse(x, r(i)?);
match (self.kind, self.form) {
(_, Form::Lock) => {}
(_, Form::PE { swap }) => {
let (p, e) = if swap { (pt(1)?, el(0)?) } else { (pt(0)?, el(1)?) };
out.push(e.on(p));
}
(_, Form::LE { swap }) => {
let ((a, b), e) = if swap { (ln(1)?, el(0)?) } else { (ln(0)?, el(1)?) };
let d = dir_n(a, b);
out.push(self.sign * d.cross(e.c - a) - e.support(d.perp()));
}
(_, Form::Contact) => {
let (p0, d0) = self.curve_at(m, x, 0, *x.get(self.aux)?)?;
let (p1, d1) = self.curve_at(m, x, 1, *x.get(self.aux + 1)?)?;
out.extend([p0.x - p1.x, p0.y - p1.y, d0.normalized().cross(d1.normalized())]);
}
(_, Form::EndOn { spline, at_end }) => {
let f = m.end_frame(x, r(spline)?, at_end)?;
let o = 1 - spline.min(1);
if let Some((a, b)) = ln(o) {
let d = dir_n(a, b);
out.extend([d.cross(f.p - a), f.t.cross(d)]);
} else if let Some((c, rad)) = ci(o) {
out.extend([f.p.dist(c) - rad, f.t.dot(dir_n(c, f.p))]);
} else {
let e = el(o)?;
out.extend([e.on(f.p), f.t.dot(e.normal(f.p))]);
}
}
(_, Form::Join { a_end, b_end, g2 }) => {
// Orient both so the first curve runs into the joint and the second out of it.
let fa = m.end_frame(x, r(0)?, a_end)?;
let fb = m.end_frame(x, r(1)?, b_end)?;
let fa = if a_end { fa } else { fa.reversed() };
let fb = if b_end { fb.reversed() } else { fb };
out.extend([fa.p.x - fb.p.x, fa.p.y - fb.p.y, fa.t.cross(fb.t)]);
if g2 {
out.push(fa.k - fb.k);
}
}
(_, Form::Anchor(a)) => {
let p = pt(0)?;
out.extend([p.x - a.x, p.y - a.y]);
}
(K::Coincident, Form::PP) => {
let (p, q) = (pt(0)?, pt(1)?);
out.extend([p.x - q.x, p.y - q.y]);
}
(K::Coincident, Form::PL { swap }) => {
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
out.push(dir_n(a, b).cross(p - a));
}
(K::Coincident, Form::PC { swap }) => {
let (p, (c, rad)) = if swap { (pt(1)?, ci(0)?) } else { (pt(0)?, ci(1)?) };
out.push(p.dist(c) - rad);
}
(K::Horizontal | K::Vertical, f) => {
let (a, b) = if f == Form::L1 { ln(0).or_else(|| el(0).map(|e| (e.c - e.mj, e.c + e.mj)))? } else { (pt(0)?, pt(1)?) };
out.push(if self.kind == K::Horizontal { a.y - b.y } else { a.x - b.x });
}
(K::Parallel | K::Smooth, Form::LL) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(dir_n(a1, b1).cross(dir_n(a2, b2)));
}
(K::Perpendicular, _) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(dir_n(a1, b1).dot(dir_n(a2, b2)));
}
(K::Collinear, _) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let d = dir_n(a1, b1);
out.extend([d.cross(a2 - a1), d.cross(b2 - a1)]);
}
(K::Concentric, _) => {
let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| el(i).map(|e| e.c)).or_else(|| pt(i));
let (p, q) = (center(0)?, center(1)?);
out.extend([p.x - q.x, p.y - q.y]);
}
(K::Equal, Form::LL) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
out.push(a1.dist(b1) - a2.dist(b2));
}
(K::Equal, _) => {
let ((_, r1), (_, r2)) = (ci(0)?, ci(1)?);
out.push(r1 - r2);
}
(K::Tangent | K::Smooth, Form::LC { swap }) => {
let ((a, b), (c, rad)) = if swap { (ln(1)?, ci(0)?) } else { (ln(0)?, ci(1)?) };
out.push(self.sign * dir_n(a, b).cross(c - a) - rad);
}
(K::Tangent | K::Smooth, _) => {
let ((c1, r1), (c2, r2)) = (ci(0)?, ci(1)?);
let d = c1.dist(c2);
out.push(if self.sign > 0.0 { d - (r1 + r2) } else { d - self.target * (r1 - r2) });
}
(K::Symmetric, Form::PP) => {
let (p, q, (a, b)) = (pt(0)?, pt(1)?, ln(2)?);
let d = dir_n(a, b);
out.extend([d.cross(p.mid(q) - a), d.dot(q - p)]);
}
(K::Symmetric, Form::SymLines { crossed }) => {
let ((a1, b1), (a2, b2), (a, b)) = (ln(0)?, ln(1)?, ln(2)?);
let (a2, b2) = if crossed { (b2, a2) } else { (a2, b2) };
let d = dir_n(a, b);
out.extend([d.cross(a1.mid(a2) - a), d.dot(a2 - a1), d.cross(b1.mid(b2) - a), d.dot(b2 - b1)]);
}
(K::Symmetric, _) => {
let ((p, r1), (q, r2), (a, b)) = (ci(0)?, ci(1)?, ln(2)?);
let d = dir_n(a, b);
out.extend([d.cross(p.mid(q) - a), d.dot(q - p), r1 - r2]);
}
(K::Distance(axis), f) => {
let v = match (axis, f) {
(_, Form::PL { swap }) => {
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
dir_n(a, b).cross(p - a)
}
_ => {
let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) };
match axis {
DistAxis::Aligned => a.dist(b),
DistAxis::Horizontal => b.x - a.x,
DistAxis::Vertical => b.y - a.y,
}
}
};
out.push(self.sign * v - self.target);
}
(K::Angular, Form::ArcSweep) => {
out.push(m.arc_sweep(x, r(0)?)? - self.target);
}
(K::Angular, Form::Rays { rev }) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
}
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
(K::Diameter, _) => out.push(2.0 * ci(0)?.1 - self.target),
_ => {}
}
Some(())
fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec<f64>) -> Option<()> {
ResidualEval { built: self, model, values, out }.evaluate()
}
/// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`.
@@ -527,6 +346,54 @@ impl Built {
/// Tangent or Smooth with a spline: the spline end and the other curve's nearest end are
/// joined (Smooth: G2; Tangent between splines: G1), or the spline's end touches a line,
/// circle, arc or ellipse tangentially. A `start`/`end` reference picks the end.
fn tangent_form(mut built: Built, constraint: &Constraint, model: &Model, values: &[f64]) -> Result<Built, String> {
use ConstraintKind as K;
let need = |what: &str| Err(format!("{} needs {what}", constraint.kind.name()));
if built.refs.len() != 2 {
return need("two objects");
}
if constraint.refs.iter().any(|reference| model.is_spline(reference)) {
return Self::spline_form(built, model, values);
}
let line = |index| r_at(constraint, index).and_then(|reference| model.line(values, reference));
let circle = |index| r_at(constraint, index).and_then(|reference| model.circle(values, reference));
let ellipse = |index| r_at(constraint, index).and_then(|reference| model.ellipse(values, reference));
built.rows = 1;
if let (Some((start, end)), Some((center, _))) = (line(0), circle(1)) {
built.form = Form::LC { swap: false };
built.sign = sgn(dir_n(start, end).cross(center - start));
} else if let (Some((start, end)), Some((center, _))) = (line(1), circle(0)) {
built.form = Form::LC { swap: true };
built.sign = sgn(dir_n(start, end).cross(center - start));
} else if let (Some((first_center, first_radius)), Some((second_center, second_radius))) = (circle(0), circle(1)) {
built.form = Form::CC;
// sign > 0: external tangency; < 0: internal.
let distance = first_center.dist(second_center);
built.sign = if distance >= first_radius.abs().max(second_radius.abs()) { 1.0 } else { -1.0 };
built.target = sgn(first_radius - second_radius);
} else if let (Some((start, end)), Some(ellipse)) = (line(0), ellipse(1)) {
built.form = Form::LE { swap: false };
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
} else if let (Some((start, end)), Some(ellipse)) = (line(1), ellipse(0)) {
built.form = Form::LE { swap: true };
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
} else if (circle(0).is_some() || ellipse(0).is_some()) && (circle(1).is_some() || ellipse(1).is_some()) {
built.form = Form::Contact;
built.rows = 3;
built.aux_init =
built.contact_guess(model, values).ok_or_else(|| format!("{} could not find where the curves touch", constraint.kind.name()))?;
} else if line(0).is_some() && line(1).is_some() && constraint.kind == K::Smooth {
built.form = Form::LL;
} else {
return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve");
}
Ok(built)
}
fn spline_form(mut b: Built, m: &Model, x: &[f64]) -> Result<Built, String> {
let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else {
return Err(format!("{} needs two objects", b.kind.name()));
@@ -596,3 +463,292 @@ impl Built {
if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v }
}
}
/// Shared access to a constraint's geometry, values, and output while evaluating one residual.
struct ResidualEval<'a> {
built: &'a Built,
model: &'a Model,
values: &'a [f64],
out: &'a mut Vec<f64>,
}
impl ResidualEval<'_> {
fn evaluate(&mut self) -> Option<()> {
use ConstraintKind as K;
match (self.built.kind, self.built.form) {
(_, Form::Lock) => Some(()),
(_, Form::PE { swap }) => self.eval_point_ellipse(swap),
(_, Form::LE { swap }) => self.eval_line_ellipse(swap),
(_, Form::Contact) => self.eval_contact(),
(_, Form::EndOn { spline, at_end }) => self.eval_end_on(spline, at_end),
(_, Form::Join { a_end, b_end, g2 }) => self.eval_join(a_end, b_end, g2),
(_, Form::Anchor(anchor)) => self.eval_anchor(anchor),
(K::Coincident, Form::PP) => self.eval_coincident_points(),
(K::Coincident, Form::PL { swap }) => self.eval_point_line(swap),
(K::Coincident, Form::PC { swap }) => self.eval_point_circle(swap),
(K::Horizontal | K::Vertical, form) => self.eval_horizontal_vertical(form),
(K::Parallel | K::Smooth, Form::LL) => self.eval_parallel(),
(K::Perpendicular, _) => self.eval_perpendicular(),
(K::Collinear, _) => self.eval_collinear(),
(K::Concentric, _) => self.eval_concentric(),
(K::Equal, Form::LL) => self.eval_equal_lines(),
(K::Equal, _) => self.eval_equal_circles(),
(K::Tangent | K::Smooth, Form::LC { swap }) => self.eval_tangent_line_circle(swap),
(K::Tangent | K::Smooth, _) => self.eval_tangent_circles(),
(K::Symmetric, Form::PP) => self.eval_symmetric_points(),
(K::Symmetric, Form::SymLines { crossed }) => self.eval_symmetric_lines(crossed),
(K::Symmetric, _) => self.eval_symmetric_circles(),
(K::Distance(axis), form) => self.eval_distance(axis, form),
(K::Angular, Form::ArcSweep) => self.eval_angular_arc(),
(K::Angular, Form::Rays { rev }) => self.eval_angular_rays(rev),
(K::Radius, _) => self.eval_radius(),
(K::Diameter, _) => self.eval_diameter(),
_ => Some(()),
}
}
fn reference(&self, index: usize) -> Option<&GeomRef> {
self.built.refs.get(index)
}
fn point(&self, index: usize) -> Option<Vec2> {
self.model.point(self.values, self.reference(index)?)
}
fn line(&self, index: usize) -> Option<(Vec2, Vec2)> {
self.model.line(self.values, self.reference(index)?)
}
fn circle(&self, index: usize) -> Option<(Vec2, f64)> {
self.model.circle(self.values, self.reference(index)?)
}
fn ellipse(&self, index: usize) -> Option<Ell> {
self.model.ellipse(self.values, self.reference(index)?)
}
fn eval_point_ellipse(&mut self, swap: bool) -> Option<()> {
let (point, ellipse) = if swap { (self.point(1)?, self.ellipse(0)?) } else { (self.point(0)?, self.ellipse(1)?) };
self.out.push(ellipse.on(point));
Some(())
}
fn eval_line_ellipse(&mut self, swap: bool) -> Option<()> {
let (line, ellipse) = if swap { (self.line(1)?, self.ellipse(0)?) } else { (self.line(0)?, self.ellipse(1)?) };
let (start, end) = line;
let direction = dir_n(start, end);
self.out.push(self.built.sign * direction.cross(ellipse.c - start) - ellipse.support(direction.perp()));
Some(())
}
fn eval_contact(&mut self) -> Option<()> {
let first_parameter = *self.values.get(self.built.aux)?;
let second_parameter = *self.values.get(self.built.aux + 1)?;
let (first_point, first_tangent) = self.built.curve_at(self.model, self.values, 0, first_parameter)?;
let (second_point, second_tangent) = self.built.curve_at(self.model, self.values, 1, second_parameter)?;
self.out.extend([
first_point.x - second_point.x,
first_point.y - second_point.y,
first_tangent.normalized().cross(second_tangent.normalized()),
]);
Some(())
}
fn eval_end_on(&mut self, spline: usize, at_end: bool) -> Option<()> {
let frame = self.model.end_frame(self.values, self.reference(spline)?, at_end)?;
let other = 1 - spline.min(1);
if let Some((start, end)) = self.line(other) {
let direction = dir_n(start, end);
self.out.extend([direction.cross(frame.p - start), frame.t.cross(direction)]);
} else if let Some((center, radius)) = self.circle(other) {
self.out.extend([frame.p.dist(center) - radius, frame.t.dot(dir_n(center, frame.p))]);
} else {
let ellipse = self.ellipse(other)?;
self.out.extend([ellipse.on(frame.p), frame.t.dot(ellipse.normal(frame.p))]);
}
Some(())
}
fn eval_join(&mut self, a_end: bool, b_end: bool, g2: bool) -> Option<()> {
let first = self.model.end_frame(self.values, self.reference(0)?, a_end)?;
let second = self.model.end_frame(self.values, self.reference(1)?, b_end)?;
let first = if a_end { first } else { first.reversed() };
let second = if b_end { second.reversed() } else { second };
self.out.extend([first.p.x - second.p.x, first.p.y - second.p.y, first.t.cross(second.t)]);
if g2 {
self.out.push(first.k - second.k);
}
Some(())
}
fn eval_anchor(&mut self, anchor: Vec2) -> Option<()> {
let point = self.point(0)?;
self.out.extend([point.x - anchor.x, point.y - anchor.y]);
Some(())
}
fn eval_coincident_points(&mut self) -> Option<()> {
let (first, second) = (self.point(0)?, self.point(1)?);
self.out.extend([first.x - second.x, first.y - second.y]);
Some(())
}
fn eval_point_line(&mut self, swap: bool) -> Option<()> {
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
self.out.push(dir_n(start, end).cross(point - start));
Some(())
}
fn eval_point_circle(&mut self, swap: bool) -> Option<()> {
let (point, (center, radius)) = if swap { (self.point(1)?, self.circle(0)?) } else { (self.point(0)?, self.circle(1)?) };
self.out.push(point.dist(center) - radius);
Some(())
}
fn eval_horizontal_vertical(&mut self, form: Form) -> Option<()> {
use ConstraintKind as K;
let (first, second) = if form == Form::L1 {
self.line(0).or_else(|| self.ellipse(0).map(|ellipse| (ellipse.c - ellipse.mj, ellipse.c + ellipse.mj)))?
} else {
(self.point(0)?, self.point(1)?)
};
let value = if self.built.kind == K::Horizontal { first.y - second.y } else { first.x - second.x };
self.out.push(value);
Some(())
}
fn eval_parallel(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(dir_n(first_start, first_end).cross(dir_n(second_start, second_end)));
Some(())
}
fn eval_perpendicular(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(dir_n(first_start, first_end).dot(dir_n(second_start, second_end)));
Some(())
}
fn eval_collinear(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
let direction = dir_n(first_start, first_end);
self.out.extend([direction.cross(second_start - first_start), direction.cross(second_end - first_start)]);
Some(())
}
fn eval_concentric(&mut self) -> Option<()> {
let center = |index| {
self.circle(index).map(|(center, _)| center).or_else(|| self.ellipse(index).map(|ellipse| ellipse.c)).or_else(|| self.point(index))
};
let (first, second) = (center(0)?, center(1)?);
self.out.extend([first.x - second.x, first.y - second.y]);
Some(())
}
fn eval_equal_lines(&mut self) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
self.out.push(first_start.dist(first_end) - second_start.dist(second_end));
Some(())
}
fn eval_equal_circles(&mut self) -> Option<()> {
let ((_, first_radius), (_, second_radius)) = (self.circle(0)?, self.circle(1)?);
self.out.push(first_radius - second_radius);
Some(())
}
fn eval_tangent_line_circle(&mut self, swap: bool) -> Option<()> {
let ((start, end), (center, radius)) = if swap { (self.line(1)?, self.circle(0)?) } else { (self.line(0)?, self.circle(1)?) };
self.out.push(self.built.sign * dir_n(start, end).cross(center - start) - radius);
Some(())
}
fn eval_tangent_circles(&mut self) -> Option<()> {
let ((first_center, first_radius), (second_center, second_radius)) = (self.circle(0)?, self.circle(1)?);
let distance = first_center.dist(second_center);
let residual = if self.built.sign > 0.0 {
distance - (first_radius + second_radius)
} else {
distance - self.built.target * (first_radius - second_radius)
};
self.out.push(residual);
Some(())
}
fn eval_symmetric_points(&mut self) -> Option<()> {
let (first, second, (line_start, line_end)) = (self.point(0)?, self.point(1)?, self.line(2)?);
let direction = dir_n(line_start, line_end);
self.out.extend([direction.cross(first.mid(second) - line_start), direction.dot(second - first)]);
Some(())
}
fn eval_symmetric_lines(&mut self, crossed: bool) -> Option<()> {
let ((first_start, first_end), (second_start, second_end), (line_start, line_end)) = (self.line(0)?, self.line(1)?, self.line(2)?);
let (second_start, second_end) = if crossed { (second_end, second_start) } else { (second_start, second_end) };
let direction = dir_n(line_start, line_end);
self.out.extend([
direction.cross(first_start.mid(second_start) - line_start),
direction.dot(second_start - first_start),
direction.cross(first_end.mid(second_end) - line_start),
direction.dot(second_end - first_end),
]);
Some(())
}
fn eval_symmetric_circles(&mut self) -> Option<()> {
let ((first_center, first_radius), (second_center, second_radius), (line_start, line_end)) =
(self.circle(0)?, self.circle(1)?, self.line(2)?);
let direction = dir_n(line_start, line_end);
self.out.extend([
direction.cross(first_center.mid(second_center) - line_start),
direction.dot(second_center - first_center),
first_radius - second_radius,
]);
Some(())
}
fn eval_distance(&mut self, axis: DistAxis, form: Form) -> Option<()> {
let value = match (axis, form) {
(_, Form::PL { swap }) => {
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
dir_n(start, end).cross(point - start)
}
_ => {
let (first, second) = if form == Form::L1 { self.line(0)? } else { (self.point(0)?, self.point(1)?) };
match axis {
DistAxis::Aligned => first.dist(second),
DistAxis::Horizontal => second.x - first.x,
DistAxis::Vertical => second.y - first.y,
}
}
};
self.out.push(self.built.sign * value - self.built.target);
Some(())
}
fn eval_angular_arc(&mut self) -> Option<()> {
let reference = self.reference(0)?;
self.out.push(self.model.arc_sweep(self.values, reference)? - self.built.target);
Some(())
}
fn eval_angular_rays(&mut self, reversed: [bool; 2]) -> Option<()> {
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
let (first, second) = (ray(first_start, first_end, reversed[0]), ray(second_start, second_end, reversed[1]));
let residual = wrap(first.cross(second).atan2(first.dot(second)) - self.built.sign * self.built.target);
self.out.push(residual);
Some(())
}
fn eval_radius(&mut self) -> Option<()> {
self.out.push(self.circle(0)?.1 - self.built.target);
Some(())
}
fn eval_diameter(&mut self) -> Option<()> {
self.out.push(2.0 * self.circle(0)?.1 - self.built.target);
Some(())
}
}
+2 -2
View File
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
let mut d = Drawing::default();
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
let (a0, a1) = ln(&d, a);
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
}
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
})
.unwrap();
assert!(!all_satisfied(&d));
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
let r = solve(&mut d, &SolveOptions::default()).unwrap();
assert_eq!(r.moved, vec![a]);
assert!(all_satisfied(&d));
}
+80
View File
@@ -382,3 +382,83 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
/// with the drawing unchanged.
fn refused_on_minor_axis_end(p: (f64, f64)) {
let mut d = Drawing::default();
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
let p = point(&mut d, p);
ok(&mut d, con(K::Fix, &[w(p)]));
let before = d.clone();
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
assert_eq!(d, before);
}
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
#[test]
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
refused_on_minor_axis_end((0.0, 6.0));
}
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
#[test]
fn minor_axis_end_pulled_through_the_centre_is_refused() {
refused_on_minor_axis_end((0.0, -1.0));
}
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
/// swapped, which constraints that name no axis end by index do not notice.
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
e
}
#[test]
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
ok(&mut d, con(K::Fix, &[w(l)]));
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let c = circle(&mut d, (0.0, 6.0), 1.5);
ok(&mut d, con(K::Fix, &[w(c)]));
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
+1 -1
View File
@@ -5,7 +5,7 @@
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing
//! [`dynamic_block`](crate::dynblock::dynamic_block) reads them into the model below without changing the kept data, so a drawing
//! saved again still carries them exactly as read.
//!
//! Every count and index here comes from the file: they are capped by the data actually present
+42 -4
View File
@@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() }
}
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
fn etag_in(x: &[RawTag]) -> Option<i64> {
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
}
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
/// foreign data.
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
}
/// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
}
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
@@ -542,6 +547,12 @@ fn apply(
let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone();
c.handle = nh;
// The copy's own xdata names the copy, not its original (as the kept data below does).
for t in &mut c.common.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
t.value = nh.hex();
}
}
if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v;
}
@@ -964,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
if let Some(v) = tag_value {
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
}
e
}
#[test]
fn element_tags_are_read_from_the_entitys_own_xdata() {
let d = Drawing::new_imperial();
let mut def = Block::new("DEF");
def.entities.push(tagged(1, Some("10")));
def.entities.push(tagged(2, Some("20")));
let mut anon = Block::new("*U1");
// Reordered, and the first object left out.
anon.entities.push(tagged(5, Some("20")));
anon.entities.push(tagged(6, None));
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
}
+28 -936
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
use super::common::{Common, Lineweight};
use super::text::{MText, Text};
use super::{Color, Deserialize, EntityKind, Mat3, Serialize, Vec2, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attrib {
pub tag: String,
pub text: Text,
#[serde(default)]
pub invisible: bool,
#[serde(default)]
pub constant: bool,
#[serde(default)]
pub prompt: String,
/// An attribute's own properties; each one left unset follows the attribute definition.
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
pub props: AttribProps,
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
/// multiline text. `text.value` holds the plain text, which value edits change.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiline: Option<MultilineAttrib>,
/// Its other modes: Verify, Preset and Lock position.
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
pub modes: AttribModes,
}
/// An attribute's modes beyond invisible and constant.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttribModes {
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
#[serde(default)]
pub verify: bool,
/// Preset: the default value is used without asking (DXF flag 8).
#[serde(default)]
pub preset: bool,
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
/// 280 after the flags). A reference's attributes take it from their definitions.
#[serde(default)]
pub lock_position: bool,
}
impl AttribModes {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
/// only Lock position there.
pub fn for_reference(self) -> Self {
AttribModes { lock_position: self.lock_position, ..Self::default() }
}
}
/// The multiline text of a multiline attribute.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultilineAttrib {
pub mtext: MText,
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
/// since, and its value replaces the contents.
pub read_as: String,
}
impl MultilineAttrib {
/// This text placed by `m` (a block reference's transform, for its attribute made from a
/// definition).
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
let mut k = EntityKind::MText(self.mtext.clone());
k.transform(m);
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
MultilineAttrib { mtext, read_as: self.read_as.clone() }
}
}
impl Attrib {
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
pub fn transform_multiline(&mut self, m: &Mat3) {
if let Some(ml) = &mut self.multiline {
*ml = ml.transformed(m);
}
}
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
/// replaced by the value when the value was edited since it was read.
pub fn multiline_text(&self) -> Option<MText> {
let m = self.multiline.as_ref()?;
let mut t = m.mtext.clone();
if self.text.value != m.read_as {
t.contents = mtext_literal(&self.text.value);
}
Some(t)
}
}
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
/// paragraph breaks.
pub fn mtext_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
'\n' => out.push_str("\\P"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
/// lineweight), overriding those of its definition.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AttribProps {
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<Color>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linetype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineweight: Option<Lineweight>,
}
impl AttribProps {
/// Nothing set: the attribute is drawn with its definition's properties.
pub fn is_empty(&self) -> bool {
*self == AttribProps::default()
}
/// `base` (the definition's properties) with the ones set here in place.
pub fn over(&self, base: &Common) -> Common {
Common {
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
color: self.color.unwrap_or(base.color),
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
lineweight: self.lineweight.unwrap_or(base.lineweight),
..base.clone()
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Insert {
pub block: String,
pub insert: Vec3,
#[serde(default = "unit_scale")]
pub scale: Vec3,
#[serde(default)]
pub rotation: f64,
#[serde(default)]
pub attribs: Vec<Attrib>,
#[serde(default = "one_u")]
pub cols: u32,
#[serde(default = "one_u")]
pub rows: u32,
#[serde(default)]
pub col_spacing: f64,
#[serde(default)]
pub row_spacing: f64,
}
fn unit_scale() -> Vec3 {
Vec3::new(1.0, 1.0, 1.0)
}
fn one_u() -> u32 {
1
}
impl Insert {
/// Block-to-world transform (2D part).
pub fn transform(&self, base: Vec2) -> Mat3 {
Mat3::translate(self.insert.xy())
.then_before(Mat3::rotate(self.rotation))
.then_before(Mat3::scale(self.scale.x, self.scale.y))
.then_before(Mat3::translate(-base))
}
}
+149
View File
@@ -0,0 +1,149 @@
use super::{Color, Deserialize, Serialize, Vec3};
/// A database handle (DXF group 5), unique per drawing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Handle(pub u64);
impl Handle {
pub fn hex(self) -> String {
format!("{:X}", self.0)
}
pub fn parse_hex(s: &str) -> Option<Handle> {
let s = s.trim();
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(s, 16).ok().map(Handle)
}
}
/// Lineweight in hundredths of a millimetre, or a logical value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Lineweight {
#[default]
ByLayer,
ByBlock,
Default,
/// 0..=211 (hundredths of mm).
Mm100(u16),
}
impl Lineweight {
/// The standard lineweight values (mm × 100).
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
pub fn to_dxf(self) -> i16 {
match self {
Lineweight::ByLayer => -1,
Lineweight::ByBlock => -2,
Lineweight::Default => -3,
Lineweight::Mm100(v) => v.min(211) as i16,
}
}
pub fn from_dxf(v: i16) -> Lineweight {
match v {
-2 => Lineweight::ByBlock,
-3 => Lineweight::Default,
0..=211 => Lineweight::Mm100(v as u16),
_ => Lineweight::ByLayer,
}
}
pub fn name(self) -> String {
match self {
Lineweight::ByLayer => "ByLayer".into(),
Lineweight::ByBlock => "ByBlock".into(),
Lineweight::Default => "Default".into(),
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
}
}
}
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Transparency {
#[default]
ByLayer,
ByBlock,
Percent(u8),
}
impl Transparency {
/// "ByLayer", "ByBlock" or the percentage ("50").
pub fn name(&self) -> String {
match self {
Transparency::ByLayer => "ByLayer".into(),
Transparency::ByBlock => "ByBlock".into(),
Transparency::Percent(p) => p.to_string(),
}
}
}
/// Properties every entity has.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Common {
pub layer: String,
pub color: Color,
pub linetype: String,
pub lineweight: Lineweight,
pub ltscale: f64,
pub transparency: Transparency,
pub visible: bool,
pub thickness: f64,
pub extrusion: Vec3,
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
/// style table.
pub plot_style: String,
/// The annotation scales an annotative object supports (empty: not annotative).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub anno_scales: Vec<crate::AnnoScale>,
/// Other applications' extended data (xdata) as read from the drawing file: whole
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub xdata: Vec<RawTag>,
}
impl Default for Common {
fn default() -> Self {
Common {
layer: "0".into(),
color: Color::ByLayer,
linetype: "ByLayer".into(),
lineweight: Lineweight::ByLayer,
ltscale: 1.0,
transparency: Transparency::ByLayer,
visible: true,
thickness: 0.0,
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
anno_scales: Vec::new(),
xdata: Vec::new(),
}
}
}
/// A raw DXF group: kept for round-tripping entities we do not model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
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))
}
}
+76
View File
@@ -0,0 +1,76 @@
use super::{Deserialize, PolyVertex, Serialize, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
pub a: Vec3,
pub b: Vec3,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Point {
pub p: Vec3,
#[serde(default)]
pub angle: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Circle {
pub center: Vec3,
pub radius: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Arc {
pub center: Vec3,
pub radius: f64,
/// Radians, CCW.
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ellipse {
pub center: Vec3,
pub major: Vec3,
pub ratio: f64,
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LwPolyline {
pub vertices: Vec<PolyVertex>,
pub closed: bool,
#[serde(default)]
pub const_width: f64,
#[serde(default)]
pub elevation: f64,
/// Polyline linetype generation (PLINEGEN).
#[serde(default)]
pub plinegen: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Polyline3d {
pub points: Vec<Vec3>,
pub closed: bool,
}
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Solid {
pub corners: [Vec3; 4],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Face3d {
pub corners: [Vec3; 4],
#[serde(default)]
pub hidden_edges: u8,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
pub dir: Vec3,
}
+226
View File
@@ -0,0 +1,226 @@
use super::block::Insert;
use super::common::Handle;
use super::text::MText;
use super::{Deserialize, GSpline, Serialize, Vec2, Vec3, one, standard, yes};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DimKind {
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
Linear {
rotation: f64,
},
Aligned,
Angular,
Angular3P,
Diameter,
Radius,
Ordinate {
x_type: bool,
},
ArcLength,
}
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
pub kind: DimKind,
pub defpt: Vec3,
pub text_mid: Vec3,
#[serde(default)]
pub p13: Vec3,
#[serde(default)]
pub p14: Vec3,
#[serde(default)]
pub p15: Vec3,
#[serde(default)]
pub p16: Vec3,
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
#[serde(default)]
pub text: String,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub measurement: f64,
#[serde(default)]
pub text_rotation: f64,
/// User moved the text (text_mid is authoritative).
#[serde(default)]
pub user_text_pos: bool,
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub overrides: serde_json::Map<String, serde_json::Value>,
/// Associativity: which definition points follow which objects.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assoc: Vec<DimAssoc>,
/// Oblique extension lines, jogs, breaks and inspection frames.
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
pub marks: DimMarks,
}
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
/// DIMBREAK and DIMINSPECT.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimMarks {
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
/// perpendicular to the dimension line.
#[serde(skip_serializing_if = "Option::is_none")]
pub oblique: Option<f64>,
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
#[serde(skip_serializing_if = "Option::is_none")]
pub jog_line: Option<Vec3>,
/// A jogged radius dimension (DIMJOGGED).
#[serde(skip_serializing_if = "Option::is_none")]
pub jogged: Option<JoggedRadius>,
/// Gaps in the dimension and extension lines (DIMBREAK).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub breaks: Vec<DimBreak>,
/// Inspection frame, label and rate (DIMINSPECT).
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect: Option<Inspection>,
}
impl DimMarks {
pub fn is_empty(&self) -> bool {
*self == DimMarks::default()
}
/// Transform the points and angles (part of [`super::EntityKind::transform`]).
pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) {
if let Some(a) = &mut self.oblique {
*a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot });
}
if let Some(j) = &mut self.jog_line {
t3(j);
}
if let Some(j) = &mut self.jogged {
t3(&mut j.center);
t3(&mut j.jog);
}
for b in &mut self.breaks {
t3(&mut b.at);
b.size *= s;
}
}
}
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
/// centre and jogs on its way to the arc.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct JoggedRadius {
/// Where the dimension line starts instead of the true centre.
pub center: Vec3,
/// The middle of the jog.
pub jog: Vec3,
}
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimBreak {
pub at: Vec3,
pub size: f64,
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
pub auto: bool,
}
/// An inspection dimension (DIMINSPECT).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Inspection {
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
pub shape: u8,
/// Text left of the value ("" = none).
pub label: String,
/// Inspection rate right of the value ("" = none).
pub rate: String,
}
/// A dimension definition point that follows an object (associative dimensions).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DimAssoc {
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
pub point: String,
/// The object it is attached to.
pub handle: Handle,
pub snap: AssocSnap,
}
/// Where on the object an associative definition point sits.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AssocSnap {
/// Start point of a line/arc (or polyline vertex 0).
Start,
/// End point of a line/arc.
End,
Mid,
/// Centre of a circle/arc.
Center,
/// On a circle/arc at a fixed angle (radians) from its centre.
OnCircle {
angle: f64,
},
/// Intersection of this (line) object with another line.
Intersection {
other: Handle,
},
/// Vertex `index` of a polyline.
Vertex {
index: usize,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Leader {
pub vertices: Vec<Vec3>,
#[serde(default = "yes")]
pub arrow: bool,
#[serde(default)]
pub spline: bool,
#[serde(default = "standard")]
pub style: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MLeader {
pub leaders: Vec<Vec<Vec3>>,
pub landing: Vec3,
pub dogleg: f64,
pub text: Option<MText>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "one")]
pub arrow_size: f64,
/// Leader lines are smooth curves through their points (leader type Spline). An empty
/// `leaders` is a multileader without a leader line (leader type None).
#[serde(default)]
pub spline: bool,
/// Block content in place of the text: a block reference placed at the landing.
#[serde(default)]
pub block: Option<Insert>,
}
impl MLeader {
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
self.leaders
.iter()
.map(|l| {
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
})
.collect()
}
}
+56
View File
@@ -0,0 +1,56 @@
use super::common::Handle;
use super::{Color, Deserialize, PolyVertex, Serialize, Vec2, one};
/// A hatch boundary loop: closed polyline with bulges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HatchLoop {
pub vertices: Vec<PolyVertex>,
#[serde(default)]
pub outer: bool,
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
/// hatch re-fills the loop from them when they change.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<Handle>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hatch {
pub pattern: String,
pub solid: bool,
pub loops: Vec<HatchLoop>,
#[serde(default = "one")]
pub scale: f64,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub associative: bool,
/// 0 normal (odd parity), 1 outer, 2 ignore.
#[serde(default)]
pub style: u8,
#[serde(default)]
pub elevation: f64,
/// Gradient fill: name + second colour.
#[serde(default)]
pub gradient: Option<Gradient>,
#[serde(default)]
pub origin: Vec2,
#[serde(default)]
pub background: Option<Color>,
/// The pattern's line families as a file defined them, for a pattern that isn't in the
/// standard library (unit scale and angle, like the library's; empty otherwise).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pattern_lines: Vec<crate::library::PatternLine>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub name: String,
pub color1: Color,
pub color2: Color,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub centered: bool,
}
+168
View File
@@ -0,0 +1,168 @@
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub center: Vec3,
pub width: f64,
pub height: f64,
pub view_center: Vec2,
pub view_height: f64,
#[serde(default)]
pub id: u32,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub frozen_layers: Vec<String>,
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layer_colors: Vec<(String, Color)>,
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<Handle>,
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
#[serde(default = "yes")]
pub on: bool,
}
impl Default for Viewport {
fn default() -> Self {
Viewport {
center: Vec3::ZERO,
width: 1.0,
height: 1.0,
view_center: Vec2::ZERO,
view_height: 1.0,
id: 0,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Image {
pub insert: Vec3,
/// One pixel's width (U) and height (V) vectors in drawing units.
pub u: Vec3,
pub v: Vec3,
/// Image size in pixels.
pub size: Vec2,
/// The image file (its IMAGEDEF object's path).
pub path: String,
/// The image definition's name (its key in the drawing's image dictionary).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
#[serde(default)]
pub clipping: bool,
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
/// clip boundary, 8 transparency on.
#[serde(default = "image_display")]
pub display: u16,
/// Brightness, contrast and fade, 0..=100.
#[serde(default = "image_fifty")]
pub brightness: u8,
#[serde(default = "image_fifty")]
pub contrast: u8,
#[serde(default)]
pub fade: u8,
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
#[serde(default = "image_pixel")]
pub pixel_size: Vec2,
#[serde(default)]
pub resolution_units: u8,
}
fn image_display() -> u16 {
7
}
fn image_fifty() -> u8 {
50
}
fn image_pixel() -> Vec2 {
Vec2::new(1.0, 1.0)
}
impl Default for Image {
fn default() -> Self {
Image {
insert: Vec3::ZERO,
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(1.0, 1.0),
path: String::new(),
name: String::new(),
clip: Vec::new(),
clipping: false,
display: image_display(),
brightness: image_fifty(),
contrast: image_fifty(),
fade: 0,
pixel_size: image_pixel(),
resolution_units: 0,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableCell {
pub text: String,
#[serde(default)]
pub merged: Option<(u32, u32)>,
/// The cell's own cell style; empty: its row's.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String,
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formula: Option<String>,
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
/// `None`: its cell style's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_height: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
pub insert: Vec3,
pub col_widths: Vec<f64>,
pub row_heights: Vec<f64>,
pub cells: Vec<Vec<TableCell>>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "default_text_h")]
pub text_height: f64,
/// First row is a title (merged across, larger text).
#[serde(default)]
pub title: bool,
/// The row after the title holds column headers.
#[serde(default)]
pub header: bool,
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub row_styles: Vec<String>,
/// Table breaking (TABLEBREAK): the table drawn in several parts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breaks: Option<crate::TableBreak>,
/// The CSV file the cells come from (DATALINK).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_link: Option<crate::DataLink>,
}
fn default_text_h() -> f64 {
0.18
}
+141
View File
@@ -0,0 +1,141 @@
use super::{Deserialize, Serialize, Vec2, Vec3, one, standard};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HAlign {
#[default]
Left,
Center,
Right,
Aligned,
Middle,
Fit,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VAlign {
#[default]
Baseline,
Bottom,
Middle,
Top,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Text {
pub insert: Vec3,
#[serde(default)]
pub align_pt: Option<Vec3>,
pub height: f64,
pub value: String,
#[serde(default)]
pub rotation: f64,
#[serde(default = "one")]
pub width_factor: f64,
#[serde(default)]
pub oblique: f64,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub halign: HAlign,
#[serde(default)]
pub valign: VAlign,
}
impl Text {
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
/// aligned and fit text.
pub fn justify_point(&self) -> Vec2 {
match (self.halign, self.valign) {
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
_ => self.align_pt.unwrap_or(self.insert).xy(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MText {
pub insert: Vec3,
pub height: f64,
/// Reference rectangle width (0 = no wrapping).
#[serde(default)]
pub width: f64,
/// 1..=9: TL TC TR ML MC MR BL BC BR.
#[serde(default = "attach_tl")]
pub attach: u8,
#[serde(default)]
pub rotation: f64,
#[serde(default = "standard")]
pub style: String,
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
pub contents: String,
#[serde(default = "one")]
pub line_spacing: f64,
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
/// heights; At least (`false`, the default) lets taller characters push lines apart.
#[serde(default)]
pub line_spacing_exact: bool,
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
/// one column.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<MTextColumns>,
}
fn attach_tl() -> u8 {
1
}
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
/// as the text needs, of one height (auto height) or each its own (manual heights).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MTextColumnType {
#[default]
Static,
Dynamic,
}
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MTextColumns {
#[serde(rename = "type")]
pub kind: MTextColumnType,
/// Number of columns (static columns: the columns there are).
pub count: u16,
/// Space between two columns.
pub gutter: f64,
/// Height of every column (static, and dynamic with auto height).
pub height: f64,
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
pub auto_height: bool,
/// Dynamic columns with manual heights: the height of each column.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub heights: Vec<f64>,
/// Columns run right to left.
pub flow_reversed: bool,
}
impl MTextColumns {
/// The width of `count` columns of `column_width` and their gutters.
pub fn total_width(&self, column_width: f64) -> f64 {
let n = f64::from(self.count.max(1));
column_width * n + self.gutter.max(0.0) * (n - 1.0)
}
/// The tallest column.
pub fn max_height(&self) -> f64 {
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
}
/// Scale every length (SCALE, block insertion).
pub fn scale(&mut self, s: f64) {
self.gutter *= s;
self.height *= s;
self.heights.iter_mut().for_each(|h| *h *= s);
}
}
+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)));
+100
View File
@@ -0,0 +1,100 @@
use std::path::Path;
/// A file format, chosen by the file name's extension (ASCII case-insensitive).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Dxf,
Dwg,
/// A drawing template: DWG content.
Dwt,
Svg,
/// Gzip-compressed SVG.
Svgz,
Png,
Pdf,
}
impl Format {
/// The formats a drawing can be read from by name. DXF and DWG are also recognised by content.
pub const READ: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Svg, Format::Svgz];
/// The formats a drawing can be written to.
pub const WRITE: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Png, Format::Pdf];
/// The format `path`'s extension names. A name without an extension is DXF; an unknown
/// extension is `None`.
pub fn from_path(p: impl AsRef<Path>) -> Option<Format> {
let ext = p.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
Some(match ext.as_str() {
"dxf" | "" => Format::Dxf,
"dwg" => Format::Dwg,
"dwt" => Format::Dwt,
"svg" => Format::Svg,
"svgz" => Format::Svgz,
"png" => Format::Png,
"pdf" => Format::Pdf,
_ => return None,
})
}
/// The extension, lower case and without the dot.
pub fn extension(self) -> &'static str {
match self {
Format::Dxf => "dxf",
Format::Dwg => "dwg",
Format::Dwt => "dwt",
Format::Svg => "svg",
Format::Svgz => "svgz",
Format::Png => "png",
Format::Pdf => "pdf",
}
}
/// Written as an image (model space fitted to a frame), so framing applies. SVGZ is read only.
pub fn is_image(self) -> bool {
matches!(self, Format::Svg | Format::Png | Format::Pdf)
}
/// A drawing file (DXF, DWG, template) rather than an image.
pub fn is_drawing(self) -> bool {
matches!(self, Format::Dxf | Format::Dwg | Format::Dwt)
}
/// Written as DWG, which drops the object types the DWG writer doesn't know.
pub fn drops_unknown(self) -> bool {
matches!(self, Format::Dwg | Format::Dwt)
}
/// `formats` as "dxf, dwg, svg": the list help text shows.
pub fn list(formats: &[Format]) -> String {
formats.iter().map(|f| f.extension()).collect::<Vec<_>>().join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_path_by_extension() {
for f in [Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Svgz, Format::Png, Format::Pdf] {
assert_eq!(Format::from_path(format!("a/b.{}", f.extension())), Some(f));
assert_eq!(Format::from_path(format!("B.{}", f.extension().to_ascii_uppercase())), Some(f));
}
assert_eq!(Format::from_path("Drawing1"), Some(Format::Dxf), "no extension is DXF");
assert_eq!(Format::from_path("dir.dwg/Drawing1"), Some(Format::Dxf));
assert_eq!(Format::from_path("a."), Some(Format::Dxf));
assert_eq!(Format::from_path("a.txt"), None);
assert_eq!(Format::from_path("a.dxf.bak"), None);
assert_eq!(Format::from_path(std::path::Path::new("x.PnG")), Some(Format::Png));
}
#[test]
fn classes() {
assert!(Format::READ.contains(&Format::Svgz) && !Format::WRITE.contains(&Format::Svgz));
assert!(Format::WRITE.contains(&Format::Dwt) && !Format::READ.contains(&Format::Dwt));
assert!(Format::WRITE.iter().filter(|f| f.is_image()).count() == 3 && !Format::Svgz.is_image());
assert!(Format::Dwt.drops_unknown() && Format::Dwg.drops_unknown() && !Format::Dxf.drops_unknown());
assert!(Format::Dwt.is_drawing() && !Format::Pdf.is_drawing());
assert_eq!(Format::list(Format::READ), "dxf, dwg, svg, svgz");
}
}
+12 -4
View File
@@ -10,6 +10,7 @@ mod array;
mod constraint;
mod entity;
mod extents;
mod format;
mod gdt;
mod header;
pub mod library;
@@ -26,7 +27,8 @@ 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};
pub use plotstyle::*;
@@ -367,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));
}
}
}
+45 -33
View File
@@ -441,6 +441,49 @@ impl DimStyle {
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
}
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
/// the text or number can be read as it.
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
use serde_json::Value;
match (old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
}
}
/// Whether `coerced` is outside the range its `field` can take.
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
let x = coerced.as_f64();
match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
}
}
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values.
@@ -458,29 +501,7 @@ impl DimStyle {
rejected.push(k.clone());
continue;
};
let coerced = match (&old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
};
let coerced = Self::coerce_value(&old, v, field);
// Integers stored in u8 fields: round floats.
let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
@@ -502,16 +523,7 @@ impl DimStyle {
_ => coerced,
};
// Sizes that can't be negative (text height and tolerance scale: not zero either).
let x = coerced.as_f64();
let out_of_range = match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
};
let out_of_range = Self::out_of_range(field, &coerced);
if out_of_range {
rejected.push(k.clone());
continue;
+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 {
+121 -27
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,18 +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) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
Value::Str(s) => escape_unicode(s, &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
}
@@ -433,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 {
@@ -445,13 +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());
if utf8 {
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
} else {
escape_unicode(&s, &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 => {
@@ -505,15 +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. Line breaks become spaces.
fn escape_unicode(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'\r' | '\n' => out.push(' '),
c if c.is_ascii() => out.push(c),
c => {
/// 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, 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}");
@@ -523,6 +584,20 @@ fn escape_unicode(s: &str, 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 {
@@ -569,6 +644,25 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
mod tests {
use super::*;
#[test]
fn literal_escape_text_roundtrips() {
for utf8 in [false, true] {
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
let tags = vec![Tag::s(8, text)];
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
assert_eq!(back, tags, "utf8={utf8} {text}");
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
}
}
}
#[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) {
+61 -5
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
@@ -208,6 +232,12 @@ impl Autosaver {
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
continue;
}
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
// they can't be put there, nothing is written and nothing is marked as covered, so the
// next pass tries again instead of keeping the host without them.
let Ok(doc) = st.recovery_drawing() else {
continue;
};
let path = t
.file
.clone()
@@ -216,8 +246,7 @@ impl Autosaver {
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
t.since = None;
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
jobs.push(Job::Write { uid: st.uid, path, doc });
}
jobs
}
@@ -251,7 +280,10 @@ mod native {
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::*;
use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
};
/// A new instance id: the process id and the start time.
pub fn instance_id() -> String {
@@ -384,6 +416,13 @@ mod native {
writes
}
/// Whether a background write hasn't finished yet. Its file can exist before it finishes
/// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the
/// drawing is left out of planning.
pub fn writing(&self) -> bool {
self.running.iter().any(|(_, h)| !h.is_finished())
}
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
@@ -470,3 +509,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());
}
}
}
+44 -5
View File
@@ -144,6 +144,11 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
d.groups = host.groups.clone();
@@ -171,6 +176,21 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
}
impl DocState {
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
pub fn drawing(&self) -> &Arc<Drawing> {
@@ -195,12 +215,13 @@ impl DocState {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
}
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them.
pub fn recovery_drawing(&self) -> Arc<Drawing> {
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
/// block (it would contain itself): the host drawing alone would lose them.
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
Some(be) => be.host.doc.clone(),
None => self.doc.clone(),
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
Some(be) => Ok(be.host.doc.clone()),
None => Ok(self.doc.clone()),
}
}
@@ -271,6 +292,24 @@ impl DocState {
let d = be.compose(&self.doc, &name)?;
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
// When BSAVEAS saves the block under another name, the editor's drawing and each undo and
// redo step get the block as saved and the edited block as the drawing has it, so a later
// save and the description shown start from them after an undo too.
if !name.eq_ignore_ascii_case(&be.name)
&& let Some(b) = d.block(&name).cloned()
{
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
reconcile(&mut self.doc);
for step in self.undo.iter_mut().chain(self.redo.iter_mut()) {
reconcile(&mut step.doc);
}
}
let editor = self.doc.clone();
let Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone();
@@ -33,7 +33,8 @@ pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
pub(crate) use props::set_current_color_value;
pub(crate) use props::UNIT_RANGES;
pub(crate) use props::{current_color_value, set_current_color_value};
mod qselect;
mod selecttools;
mod settings;
+2 -1
View File
@@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::*;
use super::{CommandSpec, bad, f64_req, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc};
use super::{
Accept, AssocSnap, DimAssoc, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, Segment, Session, Step, Value, Vec2, bad, json,
point_req, targets, v3,
};
/// Two-line angular dimension (type 2) between two lines, attached to them.
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
let d = s.doc().ok()?;
let line = |h: Handle| match &d.entity(h)?.kind {
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
_ => None,
};
let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
// Use the leg ends on the side of the picked arc location (the quadrant the user meant).
let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| {
let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) };
let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) };
let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized());
if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir }
};
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
if let EntityKind::Dimension(d) = &mut k {
d.p16 = v3(at);
}
let mut assoc = Vec::new();
if assoc_enabled(s) {
assoc = vec![
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
];
}
Some(with_assoc(k, assoc))
}
pub(super) fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
let hs: Vec<Handle> = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) };
let at = point_req("dimangular", p, "at")?;
let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?;
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
if let Some(h) = hex_param(p, "arc") {
let at = point_req("dimangular", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimangular", "object is not an arc")),
};
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
let v = point_req("dimangular", p, "vertex")?;
let a = point_req("dimangular", p, "p1")?;
let b = point_req("dimangular", p, "p2")?;
let at = point_req("dimangular", p, "at")?;
if v.near(a, 1e-12) || v.near(b, 1e-12) {
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
}
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?;
let at = point_req("dimarc", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimarc", "object is not an arc")),
};
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
#[derive(Default)]
pub(super) struct AngularM {
handles: Vec<Handle>,
first: Option<(Vec2, Vec2)>,
second: Option<(Vec2, Vec2)>,
vertex_mode: Vec<Vec2>,
vertex: bool,
arc: Option<(Vec2, Vec2, Vec2)>,
text: DimText,
/// Quadrant: a point inside the angle to measure; the arc location then only sets the radius.
quadrant: Option<Vec2>,
asking_quadrant: bool,
}
impl AngularM {
/// (vertex, p1, p2) of the angle being dimensioned.
fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> {
if let Some(a) = self.arc {
return Some(a);
}
if let [v, a, b] = self.vertex_mode.as_slice() {
return Some((*v, *a, *b));
}
let ((a1, a2), (b1, b2)) = (self.first?, self.second?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q };
Some((v, far(a1, a2), far(b1, b2)))
}
/// The arc location `at`, turned into the locked quadrant (same radius) when one is set.
fn located(&self, v: Vec2, at: Vec2) -> Vec2 {
match self.quadrant {
Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v),
_ => at,
}
}
}
impl Interactive for AngularM {
fn name(&self) -> &'static str {
"DIMANGULAR"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if self.asking_quadrant {
return Prompt::new("Specify quadrant", Accept::POINT);
}
if self.geometry().is_some() {
return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]);
}
if self.vertex {
return Prompt::new(
["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"]
.get(self.vertex_mode.len())
.copied()
.unwrap_or(""),
Accept::POINT,
);
}
match self.first {
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
Some(_) => Prompt::new("Select second line", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if self.asking_quadrant {
if let Input::Point(q) = i {
self.quadrant = Some(q);
}
self.asking_quadrant = false;
return Ok(Step::Continue);
}
if let Some((v, a, b)) = self.geometry() {
return match i {
Input::Point(at) => {
let at = self.located(v, at);
let k = match (self.handles.as_slice(), self.arc) {
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
_ => None,
};
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
add_dim(s, self.text.apply(k))?;
Ok(Step::Done)
}
Input::Keyword(k) => {
if k == "Quadrant" {
self.asking_quadrant = true;
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
}
if self.vertex {
match i {
Input::Point(p) => self.vertex_mode.push(p),
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
}
match i {
Input::Enter if self.first.is_none() => {
self.vertex = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let Some(h) = pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let circle = s
.doc()?
.entity(h)
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
if let Some(g) = circle.filter(|_| self.first.is_none()) {
// The center is the vertex and the pick the first endpoint; ask for the second.
self.vertex = true;
self.vertex_mode = vec![g.center, g.closest(p)];
return Ok(Step::Continue);
}
match segment_at(s, h, p) {
Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => {
self.arc = Some((g.center, g.start_point(), g.end_point()));
}
Some(Segment::Line(l)) => {
self.handles.push(h);
let seg = (l.a, l.b);
if self.first.is_none() {
self.first = Some(seg);
} else {
self.second = Some(seg);
}
}
_ => s.echo("Select an arc, circle or line."),
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
}
}
#[derive(Default)]
pub(super) struct ArcLenM {
arc: Option<cadcraft_geom::Arc>,
text: DimText,
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
partial: Option<Vec<Vec2>>,
}
/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction).
fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option<cadcraft_geom::Arc> {
let sweep = arc.sweep();
// Offset along the arc from its start, clamped to the nearer end when the point is off the arc.
let along = |x: Vec2| {
let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x));
if t <= sweep {
t
} else if t - sweep < std::f64::consts::TAU - t {
sweep
} else {
0.0
}
};
let (t1, t2) = (along(p), along(q));
let (lo, hi) = (t1.min(t2), t1.max(t2));
(hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi))
}
impl Interactive for ArcLenM {
fn name(&self) -> &'static str {
"DIMARC"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if let Some(picked) = &self.partial {
return match picked.first() {
None => Prompt::new("Specify first point for arc length dimension", Accept::POINT),
Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f),
};
}
match self.arc {
None => Prompt::new("Select arc or polyline arc segment", Accept::POINT),
Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if let Some(picked) = self.partial.take() {
match (i, picked.first().copied(), self.arc) {
(Input::Point(p), None, _) => self.partial = Some(vec![p]),
(Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) {
Some(part) => self.arc = Some(part),
None => s.echo("The two points must be different."),
},
(Input::Enter, ..) => {}
_ => self.partial = Some(picked),
}
return Ok(Step::Continue);
}
match (self.arc, i) {
(None, Input::Point(p)) => {
if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end));
} else {
s.echo("Select an arc.");
}
Ok(Step::Continue)
}
(Some(g), Input::Point(at)) => {
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
if k == "Partial" {
self.partial = Some(Vec::new());
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.partial.is_some() {
return Vec::new();
}
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
}
}
+183
View File
@@ -0,0 +1,183 @@
use super::{Accept, DimAssoc, DimKind, Dimension, EntityKind, Handle, Input, Prompt, Result, Segment, Session, Value, Vec2, Vec3, v3};
pub(super) fn style(s: &Session) -> String {
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
}
pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind {
EntityKind::Dimension(Dimension {
kind,
defpt: v3(defpt),
text_mid: Vec3::ZERO,
p13: v3(p13),
p14: v3(p14),
p15: v3(p15),
p16: v3(Vec2::ZERO),
text: text.into(),
style: style(s),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule).
fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 {
let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x));
let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y));
let outside_y = at.y < y0 || at.y > y1;
let outside_x = at.x < x0 || at.x > x1;
if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 }
}
pub(super) fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option<f64>, aligned: bool) -> DimKind {
if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } }
}
/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints,
/// vertices or centres are attached to them (associative dimensions).
pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
let mut k = k;
if let EntityKind::Dimension(dm) = &mut k
&& dm.assoc.is_empty()
&& assoc_enabled(s)
{
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)
}
pub(super) fn assoc_enabled(s: &Session) -> bool {
s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false)
}
pub(super) fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
match k {
EntityKind::Dimension(mut d) => {
d.assoc = assoc;
EntityKind::Dimension(d)
}
other => other,
}
}
/// Centre and radius of a circle/arc entity.
pub(super) fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Circle(c) => Some((c.center.xy(), c.radius)),
EntityKind::Arc(a) => Some((a.center.xy(), a.radius)),
_ => None,
}
}
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
pub(super) fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text_mid = v3(at);
d.user_text_pos = true;
}
k
}
pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
}
// ---------------- interactive ----------------
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
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`.
pub(in crate::cmd) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed }
.segments()
.into_iter()
.min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))),
_ => None,
}
}
/// What a dimension placement prompt asks after Mtext, Text or Angle.
#[derive(Clone, Copy, PartialEq)]
enum DimAsk {
Text,
Angle,
}
/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text
/// (`<>` stands for the measurement) and rotate the text.
#[derive(Default)]
pub(super) struct DimText {
text: String,
angle: f64,
asking: Option<DimAsk>,
}
impl DimText {
/// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword.
pub(super) fn keyword(&mut self, k: &str) -> bool {
self.asking = match k {
"Mtext" | "Text" => Some(DimAsk::Text),
"Angle" => Some(DimAsk::Angle),
_ => return false,
};
true
}
pub(super) fn prompt(&self) -> Option<Prompt> {
Some(match self.asking? {
DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT),
DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER),
})
}
/// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open.
pub(super) fn answer(&mut self, s: &mut Session, i: &Input) -> bool {
let Some(a) = self.asking else { return false };
match (a, i) {
(DimAsk::Text, Input::Text(t)) => self.text = t.clone(),
(DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) {
Some(r) => self.angle = r,
None => {
s.echo("Requires a valid angle.");
return true;
}
},
_ => {}
}
self.asking = None;
true
}
/// `k` with this text and text angle.
pub(super) fn apply(&self, mut k: EntityKind) -> EntityKind {
if let EntityKind::Dimension(d) = &mut k {
d.text = self.text.clone();
d.text_rotation = self.angle;
}
k
}
}
+374
View File
@@ -0,0 +1,374 @@
use super::common::{hex_param, style};
use super::{
Accept, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, Vec2, bad, json,
point_param, str_param, targets, v3,
};
pub(super) fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let st = style(s);
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
let _ = d.modify_entity(h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.style = st.clone();
dm.block = None;
n += 1;
}
});
}
Ok(json!({ "updated": n }))
}
/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text.
pub(super) fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("text").is_none() {
return crate::cmd::props::dim_override(s, p);
}
let hs = targets(s, p)?;
let t = str_param(p, "text").unwrap_or("").to_string();
// Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes.
let vars: serde_json::Map<String, Value> = p
.as_object()
.map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let mut ignored = Value::Null;
if !vars.is_empty() {
let mut rest = vars;
rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
ignored = crate::cmd::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null);
}
let d = s.doc_mut()?;
for h in &hs {
let _ = d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.text = t.clone();
dm.block = None;
}
});
}
match ignored {
Value::Null => Ok(json!({ "changed": hs.len() })),
ig => Ok(json!({ "changed": hs.len(), "ignored": ig })),
}
}
pub(super) fn dims_of(s: &Session, hs: &[Handle]) -> Vec<Handle> {
let Ok(d) = s.doc() else { return Vec::new() };
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect()
}
pub(super) fn run_reassociate(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
let space = s.space();
let mut plan = Vec::new();
{
let d = s.doc()?;
for h in &hs {
if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) {
plan.push((*h, crate::assoc::reassociate(d, &space, dm)));
}
}
}
let mut points = 0;
let mut n = 0;
let d = s.doc_mut()?;
for (h, a) in plan {
points += a.len();
n += usize::from(!a.is_empty());
let _ = d.modify_entity(h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = a;
}
});
}
Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) }))
}
pub(super) fn run_disassociate(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
let d = s.doc_mut()?;
let mut n = 0;
for h in &hs {
let _ = d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind
&& !dm.assoc.is_empty()
{
dm.assoc.clear();
n += 1;
}
});
}
Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") }))
}
/// DIMTEDIT: move/rotate/justify dimension text.
pub(super) fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
let mut q = p.as_object().cloned().unwrap_or_default();
q.insert("mode".into(), json!(mode));
run_dimtedit(s, &Value::Object(q))
}
pub(super) fn run_dimtedit(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("dimtedit", "select dimensions"));
}
let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase();
let at = point_param(p, "at");
let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians);
if mode == "angle" && angle.is_none() {
return Err(bad("dimtedit", "`angle` (degrees) is required"));
}
let d = s.doc_mut()?;
let dimscale = d.header.f64("DIMSCALE", 1.0);
for h in &hs {
let st = match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides),
_ => continue,
};
d.modify_entity(*h, |e| {
let EntityKind::Dimension(dm) = &mut e.kind else { return };
dm.block = None;
if let Some(q) = at {
dm.text_mid = v3(q);
dm.user_text_pos = true;
// DIMTMOVE 0: the dimension line moves with the text.
st.follow_text(dm, dimscale);
}
match mode.as_str() {
"home" => {
dm.user_text_pos = false;
dm.text_rotation = 0.0;
dm.overrides.remove("textJust");
}
"angle" => dm.text_rotation = angle.unwrap_or(0.0),
"left" | "center" | "right" => {
dm.user_text_pos = false;
let j = match mode.as_str() {
"left" => 1,
"right" => 2,
_ => 0,
};
dm.overrides.insert("textJust".into(), json!(j));
}
_ => {}
}
})?;
}
Ok(json!({ "changed": hs.len() }))
}
/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension.
pub(super) fn run_dimspace(s: &mut Session, p: &Value) -> Result<Value> {
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
let hs: Vec<Handle> = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect();
let d = s.doc()?;
let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) };
let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides);
let spacing = p
.get("spacing")
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0);
let dir = match b.kind {
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(),
_ => return Err(bad("dimspace", "base must be a linear or aligned dimension")),
};
let n = dir.perp();
let base_off = (b.defpt.xy() - b.p13.xy()).dot(n);
let side = if base_off >= 0.0 { 1.0 } else { -1.0 };
// Order the others by their current distance from the base line.
let mut others: Vec<(Handle, f64)> = hs
.iter()
.filter_map(|h| match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => {
Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side))
}
_ => None,
})
.collect();
others.sort_by(|a, c| a.1.total_cmp(&c.1));
let line_pt = b.defpt.xy();
let dm = s.doc_mut()?;
for (i, (h, _)) in others.iter().enumerate() {
let target = line_pt + n * (side * spacing * (i as f64 + 1.0));
dm.modify_entity(*h, |e| {
if let EntityKind::Dimension(o) = &mut e.kind {
let cur = o.defpt.xy();
let moved = cur + n * (target - cur).dot(n);
o.defpt = v3(moved);
o.user_text_pos = false;
o.block = None;
}
})?;
}
Ok(json!({ "spaced": others.len() }))
}
/// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then
/// the spacing (Enter or Auto takes the DIMDLI-based default).
#[derive(Default)]
pub(super) struct DimSpaceM {
base: Option<Handle>,
sel: SelectPhase,
spacing_phase: bool,
}
impl DimSpaceM {
fn apply(&self, s: &mut Session, base: Handle, spacing: Option<f64>) -> Result<Step> {
let mut p = serde_json::Map::new();
p.insert("base".into(), json!(base.hex()));
p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::<Vec<_>>()));
if let Some(v) = spacing {
p.insert("spacing".into(), json!(v));
}
let r = run_dimspace(s, &Value::Object(p))?;
s.set_selection(Vec::new());
s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
}
impl Interactive for DimSpaceM {
fn name(&self) -> &'static str {
"DIMSPACE"
}
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::single();
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.spacing_phase {
return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto");
}
if self.base.is_none() {
return Prompt::new("Select base dimension", Accept::SELECT);
}
Prompt::new("Select dimensions to space", Accept::SELECT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.spacing_phase {
let Some(base) = self.base else { return Ok(Step::Cancel) };
return match i {
Input::Enter => self.apply(s, base, None),
Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => {
self.apply(s, base, None)
}
Input::Text(t) => match crate::cmd::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) {
Some(v) => self.apply(s, base, Some(v)),
None => {
s.echo("Requires a non-negative number or Auto.");
Ok(Step::Continue)
}
},
_ => Ok(Step::Continue),
};
}
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
if self.base.is_none() {
match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) {
Some(b) => {
self.base = Some(b);
self.sel = SelectPhase::default();
}
None => {
s.echo("Select a dimension.");
self.sel = SelectPhase::single();
}
}
} else {
self.sel.picked = hs;
self.spacing_phase = true;
}
Ok(Step::Continue)
}
}
}
}
/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions.
pub(super) struct OverrideM {
vars: serde_json::Map<String, Value>,
var: Option<String>,
clear: bool,
selecting: Option<crate::cmd::machines::SelectPhase>,
}
impl OverrideM {
pub(super) fn new(_s: &Session) -> Self {
OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None }
}
fn apply(&self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
let mut p = self.vars.clone();
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
if self.clear {
p.insert("clear".into(), json!(true));
}
let r = crate::cmd::props::dim_override(s, &Value::Object(p))?;
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
}
impl Interactive for OverrideM {
fn name(&self) -> &'static str {
"DIMOVERRIDE"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(sel) = &self.selecting {
return sel.prompt();
}
match &self.var {
Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT),
None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(sel) = &mut self.selecting {
return match sel.feed(s, &i)? {
crate::cmd::machines::SelOutcome::More => Ok(Step::Continue),
crate::cmd::machines::SelOutcome::Empty => Ok(Step::Done),
crate::cmd::machines::SelOutcome::Done(hs) => self.apply(s, hs),
};
}
match (self.var.take(), i) {
(None, Input::Keyword(k)) if k.starts_with("Clear") => {
self.clear = true;
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
}
(None, Input::Text(t)) => {
let t = t.trim().to_string();
if t.eq_ignore_ascii_case("c") {
self.clear = true;
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
} else if cadcraft_doc::DimStyle::field_name(&t).is_some() {
self.var = Some(t);
} else {
s.echo(format!("Unknown dimension variable \"{t}\"."));
}
}
(None, Input::Enter) => {
if self.vars.is_empty() {
return Ok(Step::Cancel);
}
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
}
(Some(v), Input::Text(t)) => {
let val = t.trim().parse::<f64>().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim()));
self.vars.insert(v, val);
}
(Some(v), _) => self.var = Some(v),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
+363
View File
@@ -0,0 +1,363 @@
use super::common::{DimText, add_dim, assoc_enabled, circle_of, dim, linear_kind, pick_at};
use super::{
Accept, DimKind, Dimension, EngineError, EntityKind, Handle, Input, Interactive, Prim, Prompt, Result, Segment, Session, Step, Value, Vec2, bad,
json, point_req, points_param, str_param, v3,
};
pub(super) fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) {
Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?,
None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?),
};
let at = point_req("dimlinear", p, "at")?;
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dimaligned", p, "p1")?;
let b = point_req("dimaligned", p, "p2")?;
let at = point_req("dimaligned", p, "at")?;
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
let f = point_req("dimordinate", p, "feature")?;
let l = point_req("dimordinate", p, "leader")?;
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
/// The last linear/aligned dimension (for CONTINUE/BASELINE).
fn last_linear(s: &Session) -> Option<Dimension> {
let h = s.last_dim?;
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()),
_ => None,
}
}
/// Next dimension in a chain from `prev` to a new second origin.
fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind {
// DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*)
// included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn.
let spacing = s
.doc()
.ok()
.map(|d| {
let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides);
st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0))
})
.filter(|v| v.is_finite())
.unwrap_or(0.38);
let dir = match prev.kind {
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
_ => (prev.p14.xy() - prev.p13.xy()).normalized(),
};
let first = if baseline { prev.p13.xy() } else { prev.p14.xy() };
let mut defpt = prev.defpt.xy();
if baseline {
// Offset the dimension line away from the measured points.
let n = dir.perp();
let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 };
defpt += n * (spacing * side);
}
EntityKind::Dimension(Dimension {
p13: v3(first),
p14: v3(next),
defpt: v3(defpt),
text: String::new(),
block: None,
assoc: Vec::new(),
marks: Default::default(),
user_text_pos: false,
..prev.clone()
})
}
/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds.
pub(super) const MAX_CHAIN: usize = 100_000;
fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result<Value> {
let id = if baseline { "dimbaseline" } else { "dimcontinue" };
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?;
if pts.len() > MAX_CHAIN {
return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call")));
}
let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?;
// One snap index for the whole chain: looking up each new point's object by scanning the
// drawing made long chains quadratic (the added dimensions are no snap targets).
let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None };
let mut out = Vec::new();
for q in pts {
let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?;
let mut k = chained(s, &prev, q, baseline);
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]);
}
let h = s.add_entity(k)?;
s.last_dim = Some(h);
out.push(h.hex());
}
Ok(json!({ "handles": out }))
}
pub(super) fn run_continue(s: &mut Session, p: &Value) -> Result<Value> {
chain_run(s, p, false)
}
pub(super) fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
chain_run(s, p, true)
}
/// The two points a picked object is dimensioned between (DIMLINEAR "select object").
fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> {
let e = s.doc().ok()?.entity(h)?;
if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) {
return Some((c - Vec2::X * r, c + Vec2::X * r));
}
e.kind.prims().into_iter().find_map(|pr| match pr {
Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())),
_ => None,
})
}
pub(super) struct LinearM {
aligned: bool,
pts: Vec<Vec2>,
rotation: Option<f64>,
text: DimText,
selecting: bool,
/// Asking for the dimension line angle (Rotated).
rotating: bool,
}
impl LinearM {
pub(super) fn new(aligned: bool) -> Self {
LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false }
}
}
impl Interactive for LinearM {
fn name(&self) -> &'static str {
if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.rotating {
return Prompt::new("Specify angle of dimension line", Accept::NUMBER);
}
if let Some(p) = self.text.prompt() {
return p;
}
if self.selecting {
return Prompt::new("Select object to dimension", Accept::POINT);
}
match self.pts.len() {
0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"),
1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()),
// Once the orientation is forced, only the text options remain.
_ if self.aligned || self.rotation.is_some() => {
Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"])
}
_ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.rotating {
if let Input::Text(t) = &i {
let Some(r) = s.angle_settings().direction(t) else {
s.echo("Requires a valid angle.");
return Ok(Step::Continue);
};
self.rotation = Some(r);
}
self.rotating = false;
return Ok(Step::Continue);
}
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if self.selecting {
if let Input::Point(p) = i {
let Some(h) = pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let seg =
s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None }));
match seg {
Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => {
self.pts = vec![sg.start(), sg.end()];
self.selecting = false;
}
None => {
if let Some((c, r)) = circle_of(s, h) {
self.pts = vec![c - Vec2::X * r, c + Vec2::X * r];
self.selecting = false;
} else {
s.echo("Object cannot be dimensioned this way.");
}
}
}
}
return Ok(Step::Continue);
}
match (self.pts.len(), i) {
(0, Input::Enter) => {
self.selecting = true;
Ok(Step::Continue)
}
(n, Input::Point(p)) if n < 2 => {
self.pts.push(p);
Ok(Step::Continue)
}
(_, Input::Point(at)) => {
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, ""));
add_dim(s, k)?;
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
let g = cadcraft_render::dimension_geometry(&d, &st, 1.0);
s.echo(format!("Dimension text = {}", g.value));
}
Ok(Step::Done)
}
(_, Input::Keyword(k)) => {
match k.as_str() {
"Horizontal" => self.rotation = Some(0.0),
"Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2),
"Rotated" => self.rotating = true,
other => {
self.text.keyword(other);
}
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.pts.as_slice() {
[a] => vec![crate::cmd::helpers::line(*a, c)],
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))],
_ => Vec::new(),
}
}
}
#[derive(Default)]
pub(super) struct OrdinateM {
feature: Option<Vec2>,
/// Xdatum / Ydatum: the forced ordinate type (`true` = X datum).
xtype: Option<bool>,
text: DimText,
}
impl OrdinateM {
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, ""))
}
}
impl Interactive for OrdinateM {
fn name(&self) -> &'static str {
"DIMORDINATE"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
match (self.feature, self.xtype) {
(None, _) => Prompt::new("Specify feature location", Accept::POINT),
(Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f),
(Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
match (self.feature, i) {
(None, Input::Point(p)) => {
self.feature = Some(p);
Ok(Step::Continue)
}
(Some(_), Input::Keyword(k)) => {
match k.as_str() {
"Xdatum" => self.xtype = Some(true),
"Ydatum" => self.xtype = Some(false),
other => {
self.text.keyword(other);
}
}
Ok(Step::Continue)
}
(Some(f), Input::Point(l)) => {
add_dim(s, self.kind(s, f, l))?;
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default()
}
}
pub(super) struct ChainM {
baseline: bool,
created: Vec<Handle>,
}
impl ChainM {
pub(super) fn new(_s: &Session, baseline: bool) -> Self {
ChainM { baseline, created: Vec::new() }
}
}
impl Interactive for ChainM {
fn name(&self) -> &'static str {
if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" }
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if last_linear(s).is_none() {
s.echo("No linear dimension to continue. Create one with DIMLINEAR first.");
return Ok(Step::Done);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
Prompt::new("Specify a second extension line origin", Accept::POINT)
.kw(&["Select", "Undo"])
.default("Select")
.base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() }))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) => {
let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?;
let k = chained(s, &prev, p, self.baseline);
self.created.push(add_dim(s, k)?);
Ok(Step::Continue)
}
Input::Keyword(k) if k == "Undo" => {
if let Some(h) = self.created.pop() {
s.doc_mut()?.remove_entity(h);
s.last_dim = self.created.last().copied();
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default()
}
}
+392
View File
@@ -0,0 +1,392 @@
use super::common::style;
use super::{
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
bad, json, points_param, str_param, v3,
};
/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg).
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<EntityKind> {
let (landing_pt, arrow_pts) = pts.split_last()?;
let d = s.doc().ok()?;
let cur = d.header.str("CMLEADERSTYLE", "Standard");
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
let th = st.text_height;
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
let dogleg = if landing { st.dogleg } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
dogleg,
text: (!text.is_empty()).then(|| MText {
insert: v3(tpos),
height: th,
width: 0.0,
attach,
rotation: 0.0,
style: st.text_style.clone(),
contents: text.replace('\n', "\\P"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size,
spline: false,
block: None,
}))
}
pub(super) fn run_mleader(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?;
let content = crate::cmd::mleader_content::json_content(s, p)?;
// Leader type None places the content at the one point given.
if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) {
return Err(bad("mleader", "need an arrowhead and a landing point"));
}
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?;
let k = crate::cmd::mleader_content::apply(s, k, &content)?;
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?;
if pts.len() < 2 {
return Err(bad("leader", "need 2+ points"));
}
let st = style(s);
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: pts.iter().map(|q| v3(*q)).collect(),
arrow: true,
spline: false,
style: st,
}))?;
let mut out = vec![h.hex()];
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
out.push(
s.add_entity(EntityKind::MText(MText {
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
height: th,
width: 0.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
}
Ok(json!({ "handles": out }))
}
/// The MLEADER Options prompt being answered.
#[derive(Clone, Copy, PartialEq)]
enum LeaderOpt {
Menu,
LeaderType,
Landing,
ContentType,
MaxPoints,
FirstAngle,
SecondAngle,
BlockName,
BlockAttach,
}
/// Upper bound for MLEADER Maxpoints.
const MAX_LEADER_POINTS: usize = 64;
pub(super) struct MLeaderM {
order: LeaderOrder,
/// Points in the order they were placed.
pts: Vec<Vec2>,
/// All leader points are placed.
leader_done: bool,
/// The leader text once entered (Content first enters it before the leader).
text: Option<String>,
opt: Option<LeaderOpt>,
max_points: usize,
/// First and second angle constraints (radians; 0 = free).
angles: [f64; 2],
landing: bool,
content: bool,
leader_type: crate::LeaderType,
/// Block content: the block (`block_name`) instead of text.
block: bool,
block_name: String,
block_center: bool,
}
impl MLeaderM {
/// Starts with the order and Options chosen the last time (as AutoCAD does).
pub(super) fn new(s: &Session) -> Self {
let o = s.last_used.mleader;
let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS);
MLeaderM {
order: o.order,
pts: Vec::new(),
leader_done: false,
text: None,
opt: None,
max_points,
angles: o.angles,
landing: o.landing,
content: o.content,
leader_type: o.leader_type,
// Block content needs a block that still exists.
block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()),
block_name: s.last_used.mleader_block.clone(),
block_center: o.block_center,
}
}
/// Keep the order and Options for the next MLEADER.
fn remember(&self, s: &mut Session) {
s.last_used.mleader = MLeaderOptions {
order: self.order,
max_points: self.max_points,
angles: self.angles,
landing: self.landing,
content: self.content,
leader_type: self.leader_type,
block: self.block,
block_center: self.block_center,
};
s.last_used.mleader_block = self.block_name.clone();
}
/// The leader type and block content for `mleader_content::apply`.
fn content_spec(&self) -> crate::cmd::mleader_content::Content {
crate::cmd::mleader_content::Content {
leader: self.leader_type,
block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)),
}
}
/// Leader type None: the one point placed is the content location.
fn no_leader(&self) -> bool {
self.leader_type == crate::LeaderType::None
}
/// The leader from arrowhead to landing, with `extra` placed next.
fn leader(&self, extra: Option<Vec2>) -> Vec<Vec2> {
let mut pts = self.pts.clone();
pts.extend(extra);
if self.order != LeaderOrder::Arrowhead {
pts.reverse();
}
pts
}
/// `p` with the first/second angle constraint applied to the segment it ends (counted from the
/// first point placed).
fn constrained(&self, p: Vec2) -> Vec2 {
let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p };
let len = prev.dist(p);
if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 {
return p;
}
Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc)
}
/// The text is asked now: right after the content location (Content first) or after the leader.
fn wants_text(&self) -> bool {
self.content
&& !self.block
&& self.text.is_none()
&& (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader()))
}
fn finish(&self, s: &mut Session) -> Result<Step> {
if self.no_leader() && !self.content {
s.echo("A multileader needs a leader line or content; nothing was drawn.");
return Ok(Step::Cancel);
}
let text = self.text.clone().unwrap_or_default();
let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?;
let k = crate::cmd::mleader_content::apply(s, k, &self.content_spec())?;
s.add_entity(k)?;
Ok(Step::Done)
}
fn option_prompt(o: LeaderOpt) -> Prompt {
let kw = crate::cmd::curves::KW;
match o {
LeaderOpt::Menu => Prompt::new("Enter an option", kw)
.kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"])
.default("eXit options"),
LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"),
LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"),
LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"),
LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER),
LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER),
LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER),
LeaderOpt::BlockName => Prompt::new("Enter block name", kw),
LeaderOpt::BlockAttach => {
Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents")
}
}
}
/// One answer at the Options prompts; Enter keeps the current value.
fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option<LeaderOpt> {
use LeaderOpt::*;
match (o, i) {
(Menu, Input::Keyword(k)) => match k.as_str() {
"Leader type" => Some(LeaderType),
"leader lAnding" => Some(Landing),
"Content type" => Some(ContentType),
"Maxpoints" => Some(MaxPoints),
"First angle" => Some(FirstAngle),
"Second angle" => Some(SecondAngle),
_ => None,
},
(Menu, Input::Enter) => None,
(LeaderType, Input::Keyword(k)) => {
self.leader_type = match k.as_str() {
"sPline" => crate::LeaderType::Spline,
"None" => crate::LeaderType::None,
_ => crate::LeaderType::Straight,
};
Some(Menu)
}
(Landing, Input::Keyword(k)) => {
self.landing = k == "Yes";
Some(Menu)
}
(ContentType, Input::Keyword(k)) => {
self.content = k != "None";
self.block = false;
// Block content asks which block, then how it attaches.
if k == "Block" { Some(BlockName) } else { Some(Menu) }
}
(BlockName, Input::Text(t)) => match crate::cmd::mleader_content::block_name(s, &t) {
Ok(n) => {
self.block_name = n;
self.block = true;
Some(BlockAttach)
}
Err(e) => {
s.echo(e.to_string());
Some(BlockName)
}
},
(BlockName, Input::Enter) => {
// Enter keeps the block chosen before, if there is one.
if crate::cmd::mleader_content::block_name(s, &self.block_name).is_ok() {
self.block = true;
Some(BlockAttach)
} else {
s.echo("Requires the name of a block defined in the drawing.");
Some(BlockName)
}
}
(BlockAttach, Input::Keyword(k)) => {
self.block_center = k == "Center extents";
Some(Menu)
}
(MaxPoints, Input::Text(t)) => match t.trim().parse::<usize>() {
Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => {
self.max_points = n;
Some(Menu)
}
_ => {
s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}."));
Some(MaxPoints)
}
},
(FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) {
Some(a) => {
self.angles[usize::from(o == SecondAngle)] = a;
Some(Menu)
}
None => {
s.echo("Requires a valid angle.");
Some(o)
}
},
(Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => {
s.echo("Invalid option keyword.");
Some(o)
}
(_, Input::Enter) => Some(Menu),
_ => Some(o),
}
}
}
impl Interactive for MLeaderM {
fn name(&self) -> &'static str {
"MLEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(o) = self.opt {
return Self::option_prompt(o);
}
if self.wants_text() {
return Prompt::new("Enter leader text", Accept::TEXT);
}
if self.no_leader() && self.pts.is_empty() {
let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" };
return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options");
}
let last = self.pts.len() + 1 >= self.max_points;
match (self.pts.last(), self.order) {
(None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT)
.kw(&["leader Landing first", "Content first", "Options"])
.default("Options"),
(None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT)
.kw(&["leader arrowHead first", "Content first", "Options"])
.default("Options"),
(None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT)
.kw(&["leader arrowHead first", "leader Landing first", "Options"])
.default("Options"),
(Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b),
(Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b),
(Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(o) = self.opt {
self.opt = self.option(s, o, i);
self.remember(s);
return Ok(Step::Continue);
}
if self.wants_text() {
self.text = Some(if let Input::Text(t) = i { t } else { String::new() });
return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) };
}
match i {
Input::Point(p) => {
let p = self.constrained(p);
self.pts.push(p);
if self.pts.len() >= self.max_points || self.no_leader() {
self.leader_done = true;
}
}
Input::Keyword(k) if self.pts.is_empty() => {
match k.as_str() {
"leader Landing first" => self.order = LeaderOrder::Landing,
"leader arrowHead first" => self.order = LeaderOrder::Arrowhead,
"Content first" => self.order = LeaderOrder::Content,
_ => self.opt = Some(LeaderOpt::Menu),
}
self.remember(s);
}
Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu),
// Enter at "next point" ends the leader at the last point.
Input::Enter if self.pts.len() >= 2 => self.leader_done = true,
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) }
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() {
return Vec::new();
}
let text = self.text.as_deref().unwrap_or("");
mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing)
.and_then(|k| crate::cmd::mleader_content::apply(s, k, &self.content_spec()).ok())
.into_iter()
.collect()
}
}
+100
View File
@@ -0,0 +1,100 @@
use super::common::{DimText, add_dim, circle_of, dim, pick_at, text_at};
use super::{Accept, DimKind, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, point_param, targets};
fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
let id = if diameter { "dimdiameter" } else { "dimradius" };
let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) {
(c, pt, None)
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?;
let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?;
let at = point_param(p, "at");
let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4));
(c, c + (dir - c).normalized() * r, at)
};
if c.near(pt, 1e-12) {
return Err(bad(id, "`point` must differ from `center`"));
}
let k = if diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
};
let k = match at {
Some(at) => text_at(k, at),
None => k,
};
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
radial(s, p, false)
}
pub(super) fn run_diameter(s: &mut Session, p: &Value) -> Result<Value> {
radial(s, p, true)
}
pub(super) struct RadialM {
diameter: bool,
target: Option<(Vec2, f64)>,
text: DimText,
}
impl RadialM {
pub(super) fn new(diameter: bool) -> Self {
RadialM { diameter, target: None, text: DimText::default() }
}
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
let pt = c + (at - c).normalized() * r;
let k = self.text.apply(if self.diameter {
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
});
text_at(k, at)
}
}
impl Interactive for RadialM {
fn name(&self) -> &'static str {
if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
match self.target {
None => Prompt::new("Select arc or circle", Accept::POINT),
Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
match (self.target, i) {
(None, Input::Point(p)) => {
match pick_at(s, p).and_then(|h| circle_of(s, h)) {
Some(t) => self.target = Some(t),
None => s.echo("Object selected is not a circle or arc."),
}
Ok(Step::Continue)
}
(Some((c, r)), Input::Point(at)) => {
let k = self.kind(s, c, r, at);
add_dim(s, k)?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
self.text.keyword(&k);
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default()
}
}
+6 -3
View File
@@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection,
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets};
use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
@@ -280,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option
}
let tip = c + dir * r;
let jog = jog.unwrap_or_else(|| center.mid(tip));
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text);
if let EntityKind::Dimension(dm) = &mut k {
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
if at.dist(c) > r + 1e-9 {
+8 -7
View File
@@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
use super::{MAX_CHAIN, assoc_enabled, dim};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
@@ -94,9 +96,9 @@ fn plan(
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
let p = *c + dir * *r;
out.push(if mode == Mode::Diameter {
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "")
} else {
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "")
});
}
if out.is_empty() {
@@ -129,7 +131,7 @@ fn plan(
.iter()
.map(|p| {
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "")
})
.collect());
}
@@ -145,8 +147,7 @@ fn plan(
let mid = bounds.center();
let out_dir =
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
let lin =
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, "");
Ok(match mode {
Mode::Baseline => {
// From the datum (the point nearest it, by default the first) to every other point.
+14 -10
View File
@@ -17,7 +17,8 @@ use serde_json::{Value, json};
use super::helpers::{line, v3};
use super::machines::{SelOutcome, SelectPhase, number};
use super::modify::transform_entities;
use super::*;
use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod edit;
@@ -327,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),
@@ -359,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);
+2 -1
View File
@@ -16,7 +16,8 @@ use serde_json::{Value, json};
use super::super::helpers::v3;
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::*;
use super::super::{CommandSpec, bad, bool_or, point_param, targets};
use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};

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