Compare commits

..
Author SHA1 Message Date
Dylan M. TaylorandBrandon Thomas b639b18f69 fix(io): sort each space once when a DXF has several SORTENTSTABLEs for it (#2709)
Every SORTENTSTABLE rebuilt its whole entity store, so many small tables naming one large space made import quadratic. Each space is now sorted once, by the last table naming it, which already decided its order.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:08:07 -05:00
578da3ba9c fix(io): an ACAD_TABLE with no rows or no columns reads as an empty table (#2708)
* fix(io): an ACAD_TABLE with no rows or no columns reads as an empty table

The table cell budget charges rows x columns, which is zero when either dimension is, so a table declaring 10 000 rows and no columns still allocated row heights and empty row vectors for free, once per record. Such a table now keeps neither dimension.

* Test: assert the empty table is read (CodeRabbit)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:07:46 -05:00
Dylan M. Taylor f2afcd4f02 fix(engine): look names up in an index in AUDIT, layer-state restore and CAL (#2712)
AUDIT checked every layer's linetype and every style's text style by scanning the table; LAYERSTATE restore and restoring a named view's layer snapshot scanned the layer table for every saved layer; CAL scanned the variable map for every case-insensitive reference. Each now builds its name index once, keeping the same first-match and exact-match rules.
2026-11-03 09:07:10 -05:00
Dylan M. Taylor 1619e24381 fix(io): DXF import looks up linetype, style and entity layer names in a set (#2707)
LTYPE records scanned every linetype read so far, STYLE records every text style, and each entity's layer went through Drawing::ensure_layer's scan of the layer table, so a DXF with many distinct names read in quadratic time. These now use indexes built once per pass, as LAYER records already did; first-LTYPE-wins and last-STYLE-wins are unchanged.
2026-11-03 09:07:07 -05:00
ffc2c553e8 Drop missing files from the recent list when opening them fails (#2706)
* Drop missing files from the recent list when opening them fails

Fixes #2700

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

* Keep a recent file whose existence can't be checked (CodeRabbit)

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:07:02 -05:00
Khuong DoanandClaude Opus 5.5 7bb6c2f06f MINSERT: a block reference repeated in rows and columns, as AutoCAD does (#2698)
* MINSERT: a block reference repeated in rows and columns

MINSERT asks INSERT's questions, then the grid's as AutoCAD 2024 does
(measured with accoreconsole): the numbers of rows and columns (1 to
32767), the distance between rows or a unit cell when there is more than
one row, the distance between columns when there is more than one column,
then the attribute values once. A distance along an axis with one copy is
0, and one row by one column is a plain reference.

EXPLODE leaves a grid whole ("The object is minserted.") instead of
exploding its first copy and dropping the rest. MIRROR mirrors a block
reference as AutoCAD does, negating the X scale factor and a grid's
column distance, so a mirrored grid's rows stay on the right side. An
attribute with no default is asked without "<>".

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

* MINSERT JSON: refuse a distance that is not a number

`rowSpacing` and `columnSpacing` went through `f64_or`, which takes any
value that is not a number as the default, so `"rowSpacing": "15"` stored 0
and put every copy on top of the others without an error. They are now
checked as `rows` and `columns` are: absent or null is 0, a finite number is
kept, anything else is refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:58 -05:00
klerandClaude Opus 5.5 e10f394b28 i18n(sv): translate 20 new strings (#2695)
Brings the Swedish catalog up to date with upstream main (b04273e).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:54 -05:00
Dylan M. Taylor 9a112830c5 fix(engine): merge another drawing's tables and blocks in linear time (#2689)
INSERT of a file, IMPORT, paste and COMPARE export each checked every incoming layer, linetype, style and block name by scanning the growing destination vector, and restarted the anonymous block name search from 1 at every collision, so a compact drawing with many distinct names made the command quadratic. The four copies of that code become one helper (cmd/merge.rs) that looks names up in a set built once per table and continues each generated prefix from the last number it allocated. Xref loading does the same for its dependent symbols, and no longer rescans the block table after a fetch that imported nothing.
2026-11-03 09:06:49 -05:00
Tanish Manku b04273e897 Merge pull request #2685 from storytold/land/1937
Round-trip harness and corpus report: open, save, reopen and compare (#1937, E12)
2026-11-02 21:00:25 -07:00
5ed192555c EXPLODE: a block reference with different X and Y scales gives ellipses, not circles (#2649)
* EXPLODE: a block reference with different X and Y scales gives ellipses, not circles

EXPLODE of a block reference whose X and Y scales differ kept its circles
as circles with the mean scale, its arcs as circular arcs, its ellipses
with their old ratio and its polyline arcs as circular bulges: not the
shape the reference draws. BURST and INSERT with explode go the same way.

When the reference's transform isn't a similarity, a circle now becomes a
full ELLIPSE, an arc an elliptical arc, an ellipse gets its true axes, and
a polyline with arc segments (and no widths) its lines and elliptical
arcs. Everything else, and every evenly scaled or mirrored reference,
transforms as before.

The exact ellipse transform and the similarity test move unchanged from
snap.rs to cadcraft-geom (Ellipse::transformed, Mat3::similarity_scale)
and are shared by object snaps and EXPLODE.

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

* contributors: add Tyler-Chiriboga

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:54:03 -05:00
4c00f8b9e3 FIND: a Find and Replace window, not only the command line (#2607)
* FIND: report the text around each match

The result list needs to show what matched, not just its handle, so each entry now carries a snippet of the text around the first hit, the hit's offset in it and its length. Offsets count characters, not bytes, so a Chinese or Vietnamese drawing never splits a glyph.

Two optional parameters let a window search part of the space: `types` narrows the search by object type name and `handles` limits it to the objects a caller has already listed, which is what makes replacing a single row possible. Both default to the old behaviour, so scripts, LISP and the MCP server get the answer they always got.

* FIND: a Find and Replace window

FIND only had a command line: it asked for the text, ran, and printed a count, and the palette's Find and Replace tile opened the same prompt. The answer lived in the drawing and the place to read it was the command line.

This adds a window behind the same command. The result list shows each object with the hit lit up, a click selects that object and zooms to it, Replace edits the row under the cursor and Replace All the rows on the list. The window stays open while the drawing is edited, because finding and replacing is a loop: look, locate, edit, look again.

A JSON call carries its parameters, so scripts, LISP and the MCP server still get the engine's answer; only the bare command opens the window. Cmd+F reaches it from anywhere, and the twenty labels it adds are in every interface language.

* FIND: clear the window's state when it closes or the drawing changes

The result rows hold handles, and a handle only means something in the
drawing it was found in. The state now lives exactly as long as the window
does, and an epoch change while the window sits open clears the rows instead
of letting Replace All edit an object the search never matched. A successful
replacement keeps the engine's replacement report rather than letting the
re-search overwrite it with a match count.

* FIND: an empty replacement is a deletion, and stale rows die on any change

Replacing with nothing deletes the matched text, which is a legitimate
replacement, so the buttons no longer require text in the Replace field.

The result rows answer one question - this text, with these switches, in this
drawing and space. Editing the search term or either switch now clears the
rows and owes a fresh search, and so does switching between model space and a
layout: a handle means nothing outside the space it was found in.

* rustfmt: sort the module list after the merge

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:24 -05:00
7b6be7835f fix(io): a solid HATCH keeps its angle, scale and double flag (#2665)
* fix(io): a solid HATCH keeps its angle, scale and double flag

The writer wrote groups 52 (angle), 41 (scale) and 77 (double) for
pattern fills only, so a SOLID hatch read with them lost them on save.
Write them for solid fills too, as read.

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

* test(io): fixture for a solid HATCH keeps angle, scale and double flag

Add a synthetic DXF with a SOLID and an ANSI31 hatch and a test that saves it
and compares groups 52, 41 and 77 of each hatch by handle.

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

* docs(io): document the functions this change touches

Add doc comments to the functions the diff changes whose complexity is above
one, so the docstring coverage check passes.

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

* Drop the DXF fixture: the inline test covers the fix; test data belongs in a corpus repo

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:53:20 -05:00
455b2a87cb EXTEND: a polyline's arc end segment runs on round its circle (#2656)
EXTEND on an open polyline ran a ray along the chord of an arc end segment
and kept the old bulge, so the arc changed radius and centre, or the
boundary was reported missed when only the circle reached it. The arc end
segment now runs on round its own circle to the nearest crossing ahead,
turning the same way, and its bulge becomes that of the total turn, as
EXTEND does with an ARC. The polyline also keeps its constant width,
elevation and linetype generation, which the rebuilt polyline dropped.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:52:39 -05:00
Dylan M. Taylor 1b7c705fac Dynamic blocks: index kept objects once per listing; copy each value record once (#2678)
* Dynamic blocks: index kept objects once per listing and copy each value record once

Listing blocks rebuilt a handle index of every kept foreign object for each
block with an extension dictionary, multiplying the block count by the object
count. The listing now builds one ObjectIndex and asks is_dynamic_block_in per
block; dynamic_block answers None before indexing when the block has no
extension dictionary.

templates() copied a value record's tags once per dictionary key, so a record
listed under many keys was copied many times, and it looked up each
dictionary entry by scanning every object. It now resolves entries through
its handle index and copies each record at most once (CWE-400, CWE-407).

* Dynamic blocks: list a block as dynamic from its graph object without walking the graph's members
2026-11-02 22:52:35 -05:00
qshh 77142aa79e fonts: scan the Android and HarmonyOS system font directories (#2650)
* fonts: scan the Android system font directories

System fonts are the only fallback for text whose own font lacks a glyph
(CJK in an SHX big font, for example), and Android was falling through to
the XDG directories it does not have: a drawing came out in the stroke font
with missing-glyph boxes for Chinese. Search /system/fonts and the product
and vendor partitions too.

The directory list moves out of scan() so the platform's main location is
tested directly.

* fonts: scan the HarmonyOS system font directories

HarmonyOS targets are target_os = "linux" with target_env = "ohos", so they
fell into the XDG branch and found no fonts at all: Chinese text in a drawing
came out as missing-glyph boxes while the stroke font drew the ASCII around
it. Search /system/fonts there too, as on Android.

* contributors: add qinshah

Credits them by the name on their public GitHub profile.

* fonts: document the platform font directories
2026-11-02 22:52:31 -05:00
Dylan M. Taylor 31e6e16bc0 constraints: evaluate a named expression without copying its text (#2675)
* constraints: evaluate a named expression without copying its text

Env::value cloned the stored expression before parse rejected it as too
long, and failures are not cached, so an oversized imported expression
referenced from many dimensional constraints was copied on every
reference. Parse the stored text in place, and keep only a bounded
prefix of an over-long expression when it is inserted (it still fails
as too long).

* constraints: test repeated evaluation of an oversized named expression
2026-11-02 22:51:07 -05:00
Tanish Manku a4ff30d11e Round-trip harness: never read the kept copies back as corpus files 2026-11-02 20:49:27 -07:00
Tanish Manku 9d83f21910 Round-trip harness: typed tolerance error, report folders it couldn't read, --out=DIR
From review of #2685. The ast-grep string-error rule refused the tolerance
parser's String error. A folder the walk couldn't read was skipped silently, so a
strict run could pass on part of the corpus; it is now said on stderr and fails a
strict run. xtask corpus resolves --out=DIR against the caller's folder, as
--out DIR.
2026-11-02 20:36:01 -07:00
Tanish Manku 7caf369555 Merge #1937: open, save, reopen round-trip harness and corpus report (E12)
Merged onto current main: the DWG writer's per-type counts of what it leaves out
travel back from the DWG worker, the roundtrip command follows the CLI's clap
subcommands, and read_source keeps main's format detection. The docs/gaps.md
edit is dropped; maintainers update the parity rows in batches.
2026-11-02 20:19:17 -07:00
Tanish Manku f691d0b349 Merge pull request #2683 from storytold/land/1976
DXF: read up to 100 million group codes, or a set limit, without running out of memory silently (#1976)
2026-11-02 20:05:13 -07:00
Tanish Manku e24c4553d2 DXF: 100 million group codes by default, and memory for them that can be refused
The tag list for a counted ASCII file is reserved once with a fallible
allocation, so where the system refuses the memory, opening fails with an error
instead of ending the app. Growing it would also take half as much again for a
moment. 100 million rather than 150 bounds a crafted 512 MB file to about 4 GB
of tags; CADCRAFT_MAX_DXF_TAGS raises it, and the error says so.
2026-11-02 19:47:36 -07:00
Tanish Manku c602a402c3 Merge commit 'refs/pull/1976/head' of github.com:storytold/cadcraft into land/1976 2026-11-02 19:46:17 -07:00
Dylan M. Taylor d180b28ef6 Multilines: find each segment's usable neighbour direction in linear time (#2679)
* Multilines: find each segment's usable neighbour direction in two linear passes

MLine::miters searched the direction array forwards and backwards from every
vertex for the nearest non-degenerate segment, so a long run of coincident
vertices made it quadratic. The nearest usable directions ahead and behind
are now computed once, in one backward and one forward pass (CWE-407).

* Multilines: test that a run of coincident vertices at a corner takes the corner's miter
2026-11-02 21:33:55 -05:00
Tanish Manku 2e9761249a Autosave: keep every folder's lock, and don't let a failure hide or wait (#2676)
From review of #2670. Changing SAVEFILEPATH released the old folder's lock while
files remained there, so another app could offer a running app's drawings. A
write failing while the app was idle waited for the next input to be shown. A
second drawing's failure stayed hidden behind a dismissed notice.
2026-11-02 19:22:30 -07:00
Dylan M. Taylor c956baa349 constraints: share geometry preparation across an operation and budget spline rebuilds (#2677)
Endpoint residuals rebuilt a fit spline through a dense interpolation
solve on every evaluation, so many Tangent/Smooth constraints on one
spline, or numerical differentiation along other objects' parameters,
repeated identical O(n^3) rebuilds; solving had no budget for them.
purge_invalid built a fresh model per constraint, re-sampling the same
block for every reference, and an Insert naming no block scanned every
block definition case-insensitively.

- Models built for one operation share a Prep: block samples are taken
  once per block (in block coordinates, transformed per Insert), block
  names resolve through a lazily built case-insensitive index, and the
  last rebuild of each spline is reused at the same defining points.
- Comp::run and the Fix redundancy check refuse a system whose
  per-iteration residual work, including spline rebuilds, exceeds the
  limit DOF analysis already applied (MAX_EVAL_WORK).
- Spline templates keep only what a rebuild reads: no fit points for a
  control spline, no control net for a fit spline.
- purge_invalid validates every constraint with one Prep.
2026-11-02 21:17:23 -05:00
Dylan M. Taylor 997f152cea Extents: share block-bounds memoization across all the entities measured (#2681)
Each top-level bounds call started with an empty block cache, so measuring a
space's extents or building a spatial-index chunk measured a block definition
again for every reference to it: R references to a block of B entities cost
R x B. BlockBoundsCache keeps the per-depth block bounds (separately for drawn
bounds and extents, which differ on layers turned off) across one bulk
operation; Drawing::extents and the spatial index use it (CWE-407).
2026-11-02 21:16:56 -05:00
Dylan M. Taylor 0d3b3d21c0 Regions: test a loop's probe only against loops whose box holds it, within a budget (#2680)
Region::renest tested every loop's probe point against every other
tessellated loop, scanning all of its edges each time, so the work was the
loop count times the total vertex count. It now skips loops whose bounding
box does not hold the probe, and stops once MAX_RENEST_EDGE_VISITS edge tests
have been spent, leaving the flags unchanged and returning false; UNION,
SUBTRACT and INTERSECT then report the regions as too complex to combine, as
they already do for too many boundary pairs (CWE-407).
2026-11-02 21:16:51 -05:00
Dylan M. Taylor 4482316306 constraints: find the next default parameter name in one pass (#2674)
next_name tested up to 100,000 candidate names, each against every
parameter and constraint name, so a drawing with many occupied names
made adding one dimensional constraint quadratic. Collect the numeric
suffixes in use once and take the first free one.
2026-11-02 21:16:44 -05:00
Tanish Manku f6d84e28f3 DWG reader and writer run in a child process (E7) (#2673)
* DWG reader and writer run in a child process (E7)

An allocation failure or stack overflow in the third-party code ends its
process, which catch_unwind can't stop, so one bad file took every open drawing
with it. The app and CLI now run them in a copy of themselves over pipes. A
worker that dies or runs past 30 minutes costs that one open or save. If no
worker can be started, the work stays in the process, as before.

* DWG worker: keep the reply's bytes instead of copying them; skip the test without a bridge
2026-11-02 18:47:29 -07:00
Tanish Manku 806654aedf Add a security policy (E33) (#2672) 2026-11-02 18:24:51 -07:00
Tanish Manku 6c4984f5ad A panic in the interface saves the work and reports it (E4) (#2671)
The frame now runs under a guard. A panic autosaves unsaved drawings at once,
even with SAVETIME 0, and shows a notice that doesn't block saving. A panic
hook writes a local report for every panic. Panics that keep coming stop the
interface and leave a window that closes, with the recovery copies kept.
2026-11-02 18:14:29 -07:00
Tanish Manku fdfe4d95a8 Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670)
* Autosave: a failed write is shown until one works (E6)

A failure only reached the log, so someone could work for hours with no
recovery copy. The autosaver keeps why each drawing's write failed; the app
says it once at the command line and shows a notice that doesn't block saving.
It ends when a write works or the drawing no longer needs a copy.

* Crash recovery: offered at the first relaunch, not 5 minutes later (E5)

A crashed app's lock file looked alive until it went stale, so a relaunch
within 5 minutes skipped its autosaves, and a suspended app's looked dead.
A running app now holds a system lock on the file, which the OS drops however
the process ends. File age still decides where locks don't work and for lock
files of older versions, which don't record holding one.
2026-11-02 18:06:35 -07:00
Brandon ThomasandClaude Opus 5.5 f389d6a72e Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:26:12 -05:00
Tanish Manku eacdbe43ee Save: a folder that can't be made says so, not that the backup failed (#2669)
The backup ran before the folders were made. On Linux, a path under a file
gave 'the backup copy could not be written' instead of naming the folder.
2026-11-02 17:10:53 -07:00
Tanish Manku 096b3f9be4 Merge pull request #2668 from storytold/land/2260
Saves to a missing folder write the file and say which folder was created (#2260)
2026-11-02 16:51:54 -07:00
Tanish Manku bf4d51bf61 Merge #2260: a save to a missing folder writes the file and says so
The folders are made by a new writer only user-named targets use, so autosave
and a dangling symlink's target keep the strict write_atomic. Save, Save As,
Export, Plot, WBLOCK, layout templates, render and the CLI report the topmost
new folder at the command line and in createdFolder.
2026-11-02 16:48:16 -07:00
Books 2ac500925b DWG: a table cell's text height is an override only when the file flags it (part of #2501) (#2588)
* DWG: a table cell's text height is an override only when the file flags it (#2501)

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

* Parity checklist: regenerated after merging main

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

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

* Parity checklist: regenerated after merging main

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

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

* Parity checklist: regenerated after merging main

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

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

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

Part of #1039.

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

* Report viewport truncation only after the expansion budget is exceeded

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

* OVERKILL: within the tolerance doesn't chain

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* Docs: leave the parity docs to the maintainers

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Also document apply_groups.

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fmt

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

Split from #1005.

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

* A polyface face ends at its first zero index

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

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

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

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

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

---------

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

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

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

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

* Parity checklist: regenerated after merging main

* Parity checklist: regenerated after merging main

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

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

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

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

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

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

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

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

* docs: leave the generated parity checklist to maintainers

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

---------

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

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

* Parity checklist: regenerated for dimension reactor gate

* Parity checklist: regenerated after merging main

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

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

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

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

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

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

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

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

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

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

* docs: leave the generated parity checklist to maintainers

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

---------

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

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

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

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

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

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

---------

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

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

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

* quickcalc: apply the variable limit after validation

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

---------

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

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

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

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

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

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

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

* entget unescapes carets, entmake and entmod escape them

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

Tests: entget_shows_plain_carets_and_entmake_takes_them,
entmod_leaves_the_embedded_text_alone.

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

* Leave the parity docs to the maintainers

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

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

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

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

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

* Rename map_carets to map_text_groups (it encodes tabs too)

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

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

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

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

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

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

---------

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

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

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

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

* Leave the parity docs to the maintainers

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

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

* Viewport frozen layers: group 331 wins over stale xdata

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

Test: viewport_frozen_331_wins_over_the_xdata.

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

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

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

Tests: viewport_frozen_layers_read_from_331_or_341_once_each,
viewport_freeze_edited_in_autocad_is_read_as_edited.

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

* Viewport frozen layers: skip 102 application groups

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Portable fonts: match the Windows font by file name

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* -WBLOCK: only Yes replaces an existing file

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:49 -05:00
Andrea Brandi 020d81a27e chore: merge main into Italian terminology fix
Keep all contributor entries and align progress docs with main.
2026-11-01 20:18:39 +01:00
Brandon ThomasandClaude Opus 5.5 4b4ec8043e Release 0.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:59:24 -05:00
Dylan M. Taylor 7400119120 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:06 -05:00
258c89fbb0 Window opens centred; the Chinese menus have no English entries left (#1129) (#2510)
* Window opens centred; the Chinese menus have no English entries left (#1129)

The main window opened where the system cascades new windows, off to the
top-left on wide screens; it now opens centred. 26 menu and command
labels the Simplified Chinese update missed (Multileader > Collect,
Block Editor, dynamic blocks, Express Tools, Customize, ...) are
translated, and a test keeps every menu, command, tool and snap label
translated in Chinese. The start page question in the issue is left to
the owner.

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

* File > Open Recent's Clear Recent Files is translated in Chinese (#1129)

The Open Recent submenu is built at run time, so the Chinese completeness
test never saw its labels, and Clear Recent Files showed in English. The
label is now menus::CLEAR_RECENT, the test collects it with OPEN_RECENT,
and zh.tsv translates it. The other runtime-built menu labels (context
and tab menus) are all in zh.tsv already.

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

* i18n(zh): translate Change and Units, which landed after this branch (#1129)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-01 13:11:08 +01:00
Dylan M. Taylor d7ef7da872 ci: grant packages write permission to release workflow 2026-10-31 23:43:11 -04:00
midasmatterhorn 9c84141e38 style: rustfmt the write_atomic map_err in render 2026-11-01 04:56:59 +03:00
Dylan M. Taylor 5caaf73e3b packaging(flatpak): add OCI image build and GHCR publishing helper (#2577) 2026-10-31 18:52:40 -04:00
b6331c8603 -ARRAY: the classic command-line array, as AutoCAD lays it out (#2538)
* Add -ARRAY, the classic command-line array, as AutoCAD lays it out

Rectangular arrays move the copy in row r and column c by (c * column distance, r * row
distance), asking for a distance only when there is more than one row or column; polar arrays
space n items 360/n apart around a full circle and fill/(n - 1) otherwise, turning the copies or
moving them with the group 10 point of the object selected last. The copies are separate
objects; the type asked defaults to the one used last.

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

* -ARRAY: refuse an unknown type and oversized arrays before making transforms

The JSON form errors on a type other than rectangular or polar, and checks MAXARRAY before the
copies' transforms are made; the command line bounds the columns by the rows (CodeRabbit).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:34:44 -04:00
TolgaandClaude Fable 5 931c0c711a Turkish catalog: fix wrong registers and resolve term collisions (#2536)
* Turkish catalog: fix wrong registers and resolve term collisions

Proofreading by a native speaker. 32 rows, wording only:
- From/To were e-mail words (Kimden/Kime) -> Kaynak/Hedef
- ViewCube Home was Giris (login) -> Baslangic
- untranslated "or" in the zoom scale prompt -> veya
- BREAK vs Cut both "Kes" -> BREAK family uses "Kir"
- TRIM vs CLIP both "Kirp" -> TRIM uses "Buda"
- spline vs curve both "egri" -> spline stays "spline"
- plot style/preview/window said "Cizim X" (= drawing) -> "Cizdirme X"
- Elevation vs Height both "Yukseklik" -> Elevation is "Kot"

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

* Add GULERTOLGA to contributors

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 10:33:07 -04:00
2ez4gcxandClaude Opus 5.5 fa060b0998 Add Vietnamese (vi) to the interface languages (#2541)
* Add Vietnamese (vi) to the interface languages

vi.tsv covers every key of the other catalogs and the three strings none of them has yet, with
one plural form. Its tone-marked letters (U+1EA0-U+1EF9) aren't in egui's bundled faces, so the
Vietnamese interface takes the first system interface font that has them (Segoe UI, Arial, SF,
DejaVu Sans, Noto Sans) and otherwise stays English and says why, as Korean does without Hangul.
AI-assisted; native-speaker review pending.

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

* Vietnamese: the terminology Vietnamese AutoCAD users speak

Polyline, Pan, Offset, Spline and Grip stay in English as they are said in the office; Modify
is Hiệu chỉnh, LINE draws an Đoạn thẳng, Explode is Phân rã, Linetype Loại nét, the plot style
table Bảng nét in, Polar and Object Snap Tracking Dò theo góc / Dò theo điểm bắt, Settings Cài
đặt; the snap modes Endpoint, Midpoint and Nearest are Đầu mút, Điểm giữa and Điểm gần nhất.

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

* Vietnamese: name the language by the font loaded; Arabic's own message

The picker writes Tiếng Việt only when the interface's loaded system font has its letters
(another interface loads the first installed candidate, which may lack them). The font choice is
one function with a unit test, gated off wasm with its callers. Choosing Arabic without an
Arabic font no longer gets the Chinese message.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:33:03 -04:00
2ez4gcxandClaude Opus 5.5 87f8ce22f6 CHANGE: change objects by a change point or their properties, as AutoCAD does (#2534)
* Add CHANGE, changing objects by a change point or their properties as AutoCAD does

A change point moves each line's nearer end to it (level or plumb under ORTHOMODE), passes a
circle through it, and places a text, attribute definition or block reference by it when that is
the one object selected; otherwise "Change point ignored" and the object's questions follow, in
selection order. Properties is CHPROP's loop with Elev, applied as the command ends. Objects on
locked layers are left out, through selection helpers now shared with CHPROP.

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

* CHANGE: refuse a JSON call without a readable point or elevation

A `point` or `elevation` that can't be read is an error, and so is a call with neither, instead
of changing nothing.

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

* CHANGE: Elev on every kind AutoCAD sets it for; Aligned and Fit text; rotation default

Measured in AutoCAD 2024: Elev also sets the z of ellipses, multiline texts, solids and flat
3D polylines (a 3D polyline between heights keeps them), and of hatches, which stop being
associative ("Hatch boundary associativity removed."). Aligned text isn't asked its height or
rotation and Fit text its rotation, their two points setting them. The rotation default shows
as the drawing measures angles (ANGBASE 90 shows a text at 0 as 270.00). Shared z is compared
to rounding, and the radius and height prompts say what they need.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:33:00 -04:00
2ez4gcxandClaude Opus 5.5 ac886483d1 -INSERT: ATTREQ, Preset and Verify attributes as AutoCAD asks them (#2535)
* -INSERT: ATTREQ, Preset and Verify attributes as AutoCAD asks them

ATTREQ 0 inserts blocks with their attributes' default values without asking (a session
setting, as AutoCAD keeps it in the registry). Preset attributes are never asked for, "Enter
attribute values" heads the questions, and Verify attributes are asked again after "Verify
attribute values", each with the value typed.

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

* -INSERT: a click doesn't answer a Verify attribute

Only typed text or Enter answers "Verify attribute values"; anything else asks again (CodeRabbit).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:32:56 -04:00
2ez4gcxandClaude Opus 5.5 a626b71284 fix(draw): RECTANG and POLYGON vertices in AutoCAD's order; RECTANG remembers its modes (#2554)
RECTANG's polyline now starts at the first corner and runs along X to
the other corner, as AutoCAD 2024 draws it; each chamfer cuts the edge
into its corner by the first distance and the edge out by the second,
and fillet arcs start on the edge into their corner (clockwise outlines
get negative bulges). RECTANG keeps its chamfer, fillet, width,
elevation, thickness and rotation for the next rectangle, lists them as
"Current rectangle modes:" and offers the rotation as "<50.50>"; chamfer
and fillet clear each other. A circumscribed POLYGON starts at the end
of the picked edge counter-clockwise from its midpoint.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:32:53 -04:00
2ez4gcxandClaude Opus 5.5 81e93f2caf Scale heights with SCALE and keep them with OFFSET (#2558)
AutoCAD 2024's SCALE is a uniform 3D scale about the base point: every Z
and elevation scales about the base point's Z (the current ELEVATION for a
point given without one) and every thickness by the factor. cadcraft
scaled X and Y only. The JSON form also takes a Z with the base point.

OFFSET's copy now keeps the source's height: a line's end Zs, a circle's
or arc's centre Z, a polyline's elevation. A circle's or arc's copy has no
thickness and a polyline's keeps it, as AutoCAD makes them.

Grip scaling, ALIGN's scale option and MOCORO stay 2D: their base point is
often a grip or snapped point whose Z cadcraft doesn't keep.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:32:49 -04:00
f18cdcbcac Add -UNITS; show due surveyor bearings as one letter (#2562)
* Add -UNITS; show due surveyor bearings as one letter

-UNITS asks for the length format and precision, the angle format and
precision, the direction of angle 0 and whether angles turn clockwise,
with AutoCAD 2024's prompts, listings, defaults and messages. As in
AutoCAD, each answer is kept as it is given, and choosing another format
resets its precision to 4. The direction is typed in the chosen angle
format and turns as ANGDIR says.

Surveyor's units show a bearing that is due north, south, east or west as
that letter ("E"), except to whole degrees ("N 90d E"), as AutoCAD's
ANGTOS does.

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

* units: read a due bearing typed as one letter (E, N, W, S) in surveyor's units

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:32:46 -04:00
fc9d056576 Refuse mirror lines whose points are closer than the geometry EPS (#2555)
* Refuse mirror lines whose points are closer than the geometry EPS

Found while testing crates/engine/src/cmd/modify/transform.rs:run_mirror (coincidence check `a.near(b, 1e-12)`), with crates/geom/src/mat.rs:43-47 (Mat3::mirror)

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

* mirror: the interactive prompt also refuses points closer than the geometry EPS

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:32:42 -04:00
NetterSilverandClaude Haiku 5.5 0575a76291 Reject out-of-range OSMODE values in SETVAR instead of wrapping (#2531)
Found while testing crates/engine/src/sysvars.rs:282 (set, "OSMODE" branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:32:39 -04:00
NetterSilverandClaude Haiku 5.5 e28ed2018a Clamp Bounds2 width/height instead of overflowing to infinity (#2532)
Found while testing crates/geom/src/bounds.rs:49-54 (width, height)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:32:35 -04:00
NetterSilverandClaude Haiku 5.5 add1fd3528 Fix LISP atof returning a wrong value for out-of-range exponents (#2533)
Found while testing crates/engine/src/lisp/builtins/strings.rs:64-72 (atof)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:32:32 -04:00
NetterSilverandClaude Haiku 5.5 c254c51474 Reject nan and inf text values in SETVAR for real variables (#2546)
Found while testing crates/engine/src/sysvars.rs:~404-425 (generic fallthrough in set: String branch, then the `Some(HVal::Real(_))` arm)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:32:28 -04:00
NetterSilverandClaude Haiku 5.5 b0b2aba23e Reject non-finite arguments in CAL min() and max() (#2547)
Found while testing crates/engine/src/cmd/utility/calc.rs:~355-360 (call, "min" | "max" branch using f64::min/f64::max)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:32:24 -04:00
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
SBaksa 57e6a89d10 Parity checklist: regenerated after merging main 2026-10-31 12:31:34 +11:00
SBaksa 282ad11e60 Save note: a DWG loses SAT-text solids from 2013 on, and the note says so (#2334)
A real DWG write and read-back keeps a SAT-text REGION's groups up to 2010 and
returns it without them at 2013 and 2018, so the SAT-text skip in not_saved_as
applies before 2013 only. The DWG note now says solids are kept in a DXF only
when saved as 2010 or earlier.
2026-10-31 12:28:21 +11:00
SBaksa 768d25bb7d Merge origin/main into dxf2000-region 2026-10-31 11:57:51 +11:00
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
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
SBaksa 7baa970381 Save note: a data store record stores its first entity; a DWG keeps SAT-text solids (#2334)
The writer keeps an ACDSRECORD by its first 320 only, so data_store_entities now
credits that handle alone (one shared rule, data_store_owner). The DWG writer
converts a solid's SAT text itself at any version, so not_saved_as no longer
lists solids that hold SAT text as left out of a DWG.
2026-10-30 19:18:41 +11:00
SBaksa 590b97a321 Save note: QSAVE reports by the version it writes, not a version it ignores (#2334)
QSAVE takes no version and writes the drawing's own, but the SAT-text note read a `version`
passed to it, so `qsave {version: 2010}` on a 2018 drawing skipped the note while still
leaving the solids out. Only SAVEAS's requested version now picks the reporting branch.
Test: saving_sat_text_solids_as_dxf_2013_reports_them (qsave case fails without the fix).
2026-10-30 18:42:24 +11:00
SBaksa 86d010eaac DXF: SAT text is groups 1 and 3 of the modeler subclass; only ASM_Data records keep geometry (#2334)
sat_text_acis now looks for groups 1 and 3 inside AcDbModelerGeometry only (all
groups when a file has no subclass markers), and the data store counts an
entity as stored only when its ACDSRECORD holds ASM_Data. The writer and the
reader share one record splitter (data_store_records).
2026-10-30 14:59:16 +11:00
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
SBaksa fc8270d8c5 Parity checklist: regenerated after merging main 2026-10-30 12:47:03 +11:00
SBaksa b301d5782d Merge origin/main into dxf2000-region 2026-10-30 12:43:43 +11:00
SBaksa 1013b482a8 DXF: SAT-text note only for DXF output, from the host drawing, without xref contents (#2334)
The DWG bridge writes R2000 text for the DWG writer, which keeps SAT-text
solids: the skip is now keyed on the DXF's declared version, not the DWG
target. The save note reads the version and count from the host drawing while
the Block Editor is open, and leaves out xref blocks (path or xref| name), which
the DXF writer empties anyway; the xref name rule is shared with the writer.
2026-10-30 12:26:09 +11:00
SBaksa 89771962fc Parity checklist: regenerated after merging main 2026-10-30 11:26:25 +11:00
SBaksa 49fdbff529 Test: saving SAT-text solids as DXF 2013 reports them, 2010 does not (#2334) 2026-10-30 11:23:09 +11:00
SBaksa 2eecaef6c2 Parity checklist: regenerated after merging main 2026-10-30 10:41:07 +11:00
SBaksa b6c1ab74f9 DXF: leave SAT-text solids out of files of 2013 and later, with a save note (#2334)
REGION, 3DSOLID and BODY read from a DXF 2000 hold SAT text; DXF 2013 and later
keep ACIS geometry in the data store section, so writing the text there made a
file other readers reject. Those entities are now left out of 2013+ DXF output
and counted in the save note; 2010 and earlier, and DWG, are unchanged.
Test: sat_text_solids_are_left_out_of_dxf_2013_and_later.
2026-10-30 10:38:00 +11:00
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
midasmatterhorn c2cb5b8610 engine: propagate create_dir_all errors and route render PNG writes through write_atomic 2026-10-29 21:49:48 +03:00
midasmatterhorn c45b49acc7 Merge remote-tracking branch 'upstream/main' into fix-2260 2026-10-29 21:47:55 +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
Andrea Brandi 4398a1aab1 fix(it): correct CADCraft chamfer terminology 2026-10-27 18:47:15 +01:00
Francesco BVandClaude Opus 5.5 9107479ade io: open -> save -> reopen round-trip harness with loss report (FF5)
cadcraft_io::roundtrip compares a drawing with itself saved and reopened
(entities by handle, tables, blocks, layouts, unmodelled objects, xdata,
classes, header variables; numbers within a tolerance), and
roundtrip::corpus runs a folder of DXF/DWG files through DXF and DWG into
report.md and report.json; exposed as `cadcraft-cli roundtrip` and
`cargo xtask corpus`. The corpus itself stays outside the repo.

The DWG bridge now says what its writer leaves out, per DXF type
(cadcraft_dwg::dxf_to_dwg_noted, cadcraft_io::write_noted): the report
shows those records as lost on save and a strict corpus run fails on
them. OLE2FRAME, which the writer keeps with its data, is no longer
listed as not saved in a DWG.

CADCRAFT_CORPUS_STRICT=1 makes cadcraft-cli roundtrip (and so cargo xtask
corpus) fail on the same strict failures as the corpus test; xtask makes
a relative corpus folder and --out absolute before running the CLI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:45:34 +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
midasmatterhorn 62b9b5acd4 engine: save, render and export create a missing target folder instead of failing with os error 3 2026-10-27 00:58:10 +03:00
ffishmanandClaude Opus 5.5 069d276fe5 Merge upstream main into fix/dxf-codepage-text
crates/dxf/src/lib.rs: #2050's rule (a backslash that would start \U+ or \M+ is
written as \U+005C) goes into push_string, so it holds for UTF-8, code page and
pure ASCII output, the UTF-8 fallback included. A test checks literal escapes
next to code page text in ASCII and binary.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:05:36 -03:00
ffishmanandClaude Opus 5.5 ea02e45099 Merge upstream main into fix/dxf-codepage-text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:04:28 -03:00
ffishmanandClaude Opus 5.5 7af20d0bb2 Merge upstream main into fix/dxf-codepage-text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:58:00 -03:00
MrBeeboh a13870f376 dxf: check the tag limit in a const block 2026-10-22 20:56:55 -07:00
MrBeeboh 6ed822ec30 Plot style and plotter files save atomically through write_atomic (#1928 follow-up) 2026-10-22 20:22:09 -07:00
MrBeeboh a6ba747329 dxf: raise the group-code limit to 150 million and allow CADCRAFT_MAX_DXF_TAGS to override it (#1948) 2026-10-22 20:20:11 -07: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
358 changed files with 29416 additions and 1823 deletions
+1
View File
@@ -62,6 +62,7 @@ jobs:
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
packages: write
# 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/release.yml@main
with:
+6 -11
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,13 +10,13 @@ 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`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -47,25 +45,22 @@ 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.
+10 -9
View File
@@ -48,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
@@ -71,9 +71,10 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
+4 -6
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,13 +10,13 @@ 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`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
@@ -46,7 +44,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. 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.
Generated
+60 -25
View File
@@ -268,7 +268,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -632,6 +632,7 @@ dependencies = [
"muda",
"objc2-app-kit 0.3.2",
"rfd",
"ron",
"serde_json",
"sys-locale",
"thiserror 2.0.21",
@@ -643,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -659,14 +660,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -676,7 +677,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,14 +688,15 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "cadcraft-dxf"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"encoding_rs",
"log",
@@ -703,7 +705,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -724,24 +726,25 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
"icu_segmenter",
"skrifa 0.48.0",
"unicode-bidi",
]
[[package]]
name = "cadcraft-geom"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -762,7 +765,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -775,7 +778,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -789,7 +792,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -815,7 +818,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -1115,6 +1118,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1191,7 +1203,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1643,7 +1655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2435,6 +2447,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -3435,7 +3470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4030,7 +4065,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4087,7 +4122,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4562,10 +4597,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5471,7 +5506,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5894,7 +5929,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"chrono",
"serde_json",
+5 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.14.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -51,6 +51,9 @@ skrifa = "0.48"
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
unicode-bidi = "0.3.18"
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
@@ -66,7 +69,7 @@ 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
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 }
+14 -1
View File
@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
When reading a file leaves something out (records a DXF reader can't place, object records of a
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
`warnings`.
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
@@ -377,7 +382,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+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
+34
View File
@@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
Maintainers read reports as they arrive, but there is no fixed response time yet.
## Supported versions
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
## What counts
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
- **Updates.** A release must not be replaceable by someone other than the project.
## Known limits
These are known and being worked on, so they don't need a report:
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
- The web build has no autosave yet.
## What stays on your machine
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
+37
View File
@@ -81,6 +81,7 @@ pub(crate) enum Command {
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
Roundtrip(RoundtripArgs),
}
/// Print a drawing's summary as JSON
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
}
}
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
///
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
/// doesn't open, save or reopen, or loses records on save.
#[derive(Args)]
pub(crate) struct RoundtripArgs {
/// A drawing, or a folder of them
#[arg(value_name = "FILE|FOLDER")]
pub(crate) path: Option<PathBuf>,
/// Where the report goes (default: `report` beside the files)
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<PathBuf>,
/// Keep the saved files, in `saved` under the report folder
#[arg(long)]
pub(crate) keep_saved: bool,
/// Save only in each file's own format
#[arg(long)]
pub(crate) same_format: bool,
/// Don't count what each file holds (faster; no "read lost" column)
#[arg(long)]
pub(crate) no_census: bool,
/// How far numbers may move and still count as equal
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
pub(crate) tolerance: f64,
}
/// `--tolerance`: a finite number, 0 or more.
fn tolerance(v: &str) -> Result<f64, ValueError> {
match v.parse::<f64>() {
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
_ => Err(ValueError::Tolerance(v.to_string())),
}
}
/// Write a built-in sample drawing
#[derive(Args)]
pub(crate) struct SampleArgs {
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
CmdId,
#[error(transparent)]
CmdJson(#[from] serde_json::Error),
#[error("expected a number of 0 or more, got `{0}`")]
Tolerance(String),
}
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
+3 -2
View File
@@ -1,6 +1,6 @@
use super::args::ConvertArgs;
use super::error::Result;
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
use super::{open, unicode};
use cadcraft_engine::Session;
@@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
let framing = a.framing.framing();
let mut s = Session::empty();
open(&mut s, &a.input)?;
if let Some(m) = skipped_message(s.doc()?, i) {
let d = s.doc()?;
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
io.errln(format_args!("{m}"));
}
if cadcraft_io::is_image(&a.output)
+3
View File
@@ -1,5 +1,6 @@
use crate::output::OutputError;
use crate::perf::PerfError;
use crate::roundtrip::RoundtripError;
use crate::run::RunError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
Output(#[from] OutputError),
#[error(transparent)]
Perf(#[from] PerfError),
#[error(transparent)]
Roundtrip(#[from] RoundtripError),
/// The engine and io crates take file names as text.
#[error("{}: the file name is not valid Unicode", .0.display())]
NotUnicode(std::path::PathBuf),
+4 -1
View File
@@ -16,6 +16,7 @@ mod info;
mod mcp;
mod output;
mod perf;
mod roundtrip;
mod run;
mod sample;
@@ -32,6 +33,7 @@ use output::Streams;
#[cfg(test)]
use output::{export_window, model_views, write_atomic};
use perf::perf;
use roundtrip::roundtrip;
use run::run_cmds;
#[cfg(test)]
use run::{fill_last, returned_handle};
@@ -99,7 +101,7 @@ where
Command::Run(a) => a.settings.apply(),
Command::Mcp(a) => a.settings.apply(),
Command::Sample(a) => a.settings.apply(),
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
@@ -110,6 +112,7 @@ where
Command::Mcp(a) => mcp(a),
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
Command::Sample(a) => sample(a, &mut io),
Command::Roundtrip(a) => roundtrip(a, &mut io),
};
let mut code = 0;
if let Err(e) = result {
+5
View File
@@ -1,6 +1,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// A copy of this program started to read or write a DWG file does only that.
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
std::process::exit(code);
}
cadcraft_io::dwg_worker::use_own_worker();
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
// reported as an error, not panic before anything is printed.
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
+39 -4
View File
@@ -122,10 +122,21 @@ pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64
/// 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> {
/// leave the file it points to (`--in-place` through a link) unchanged. Folders missing above `out`
/// are created, and the topmost one returned for the caller to report.
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<Option<std::path::PathBuf>, 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 })
let folder = cadcraft_engine::atomic_write::missing_folder(&target);
cadcraft_engine::atomic_write::write_atomic_making_folders(&target, bytes)
.map_err(|source| OutputError::Write { path: out.to_path_buf(), source })?;
Ok(folder)
}
/// Say that `out` went into a folder that didn't exist, so a mistyped path is noticed.
pub(crate) fn report_new_folder(folder: Option<std::path::PathBuf>, io: &mut Streams) {
if let Some(folder) = folder {
io.errln(format_args!("created the folder {}, which did not exist", folder.display()));
}
}
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
@@ -135,7 +146,8 @@ pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
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)?;
let folder = write_atomic(out, &bytes)?;
report_new_folder(folder, io);
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);
@@ -165,6 +177,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
/// #859), one warning line each; empty for a complete read.
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
@@ -175,3 +193,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
#[cfg(test)]
mod tests {
use super::read_note_messages;
#[test]
fn read_notes_become_one_warning_each() {
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
assert!(read_note_messages(&d, "a.dwg").is_empty());
d.read_notes =
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
assert_eq!(
read_note_messages(&d, "a.dwg"),
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
);
}
}
+93
View File
@@ -0,0 +1,93 @@
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
use super::args::RoundtripArgs;
use super::error::{CliError, Result};
use super::output::Streams;
/// Why `roundtrip` stopped.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RoundtripError {
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
NoPath,
#[error("roundtrip: no such file or folder: {}", .0.display())]
Missing(std::path::PathBuf),
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
Empty(std::path::PathBuf),
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
Report { path: std::path::PathBuf, source: std::io::Error },
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
Strict(Vec<String>),
}
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
/// stderr, and failures of a strict run.
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
if w.truncated {
out.push("left out: files or folders past the harness limits".to_string());
}
out
}
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
use cadcraft_io::roundtrip::corpus;
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
let mut missed = Vec::new();
let (root, files, out) = if path.is_dir() {
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
// The report folder, and the saved copies in it, are never read back as corpus files.
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
missed = left_out(&walk);
for m in &missed {
io.errln(format_args!("roundtrip: {m}"));
}
(path, walk.files, out)
} else if path.is_file() {
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
(root, vec![path], out)
} else {
return Err(RoundtripError::Missing(path).into());
};
if files.is_empty() {
// A folder it couldn't read isn't an empty one.
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
}
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
opts.compare.abs_tol = a.tolerance;
if a.keep_saved {
opts.keep_saved = Some(out.join("saved"));
}
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
for f in &report.files {
let routes: Vec<String> = f
.routes
.iter()
.map(|r| match (&r.save_error, &r.reopen_error) {
(Some(_), _) => format!("{}: save failed", r.route),
(None, Some(_)) => format!("{}: reopen failed", r.route),
(None, None) => {
let t = r.comparison.total();
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
}
})
.collect();
match &f.open_error {
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
}
}
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
io.outln(format_args!("report: {}", md.display()));
io.outln(format_args!(" {}", json.display()));
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
// leaves out, fail the run (the report is written first).
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
let mut failed = report.strict_failures();
failed.extend(missed);
if !failed.is_empty() {
return Err(CliError::from(RoundtripError::Strict(failed)));
}
}
Ok(())
}
+3 -2
View File
@@ -1,6 +1,6 @@
use super::args::{SampleArgs, SampleName};
use super::error::Result;
use super::output::{Streams, write_atomic};
use super::output::{Streams, report_new_folder, write_atomic};
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
let d = match a.name {
@@ -8,7 +8,8 @@ pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
};
let bytes = cadcraft_io::write(&d, &a.output)?;
write_atomic(&a.output, &bytes)?;
let folder = write_atomic(&a.output, &bytes)?;
report_new_folder(folder, io);
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save,
//! not the app. The worker setting is process-wide, so everything runs in one test.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
use cadcraft_io::dwg_worker;
#[test]
fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
let drawing = cadcraft_engine::sample::bracket();
let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap();
dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]);
let written = cadcraft_io::write(&drawing, "a.dwg").unwrap();
assert_eq!(written.get(..6), in_process.get(..6), "the same version of file");
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
assert_eq!(read.model.len(), drawing.model.len());
// A decode error comes back as that error, not as the worker stopping.
let mut bad = in_process.clone();
bad.truncate(64);
let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string();
assert!(!e.contains("worker"), "{e}");
// The worker aborts as the third-party code can. This process goes on.
let e = dwg_worker::crash_worker_for_test().unwrap_err();
assert!(e.contains("worker stopped"), "{e}");
assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker");
// No worker to start: the work happens here, as without one.
dwg_worker::use_worker("no-such-program-here".into(), Vec::new());
assert!(cadcraft_io::read(&written, "a.dwg").is_ok());
}
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
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 subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
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")));
+69
View File
@@ -0,0 +1,69 @@
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
//! the loss. The exit code stays 0.
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
for i in 0..200 {
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
}
dxf += "0\nENDSEC\n0\nEOF\n";
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
.step_by(97)
.map(|at| {
let mut b = dwg.clone();
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
b
})
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
.expect("no damaged copy read partially");
(dwg, damaged)
}
#[test]
fn info_lists_and_convert_warns_about_read_notes() {
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (whole, damaged) = dwg_and_damaged_copy();
let path = dir.join("damaged.dwg");
std::fs::write(&path, &damaged).unwrap();
let path = path.to_str().unwrap();
let out = cli(&["info", path]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let notes = v["readNotes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{v}");
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
let copy = dir.join("copy.dxf");
let out = cli(&["convert", path, copy.to_str().unwrap()]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
assert_eq!(warnings.len(), 1, "{err}");
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
assert!(copy.exists());
// A complete read: no notes, and no warning.
let intact = dir.join("whole.dwg");
std::fs::write(&intact, &whole).unwrap();
let out = cli(&["info", intact.to_str().unwrap()]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["readNotes"], serde_json::json!([]));
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
+6
View File
@@ -24,6 +24,9 @@ log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
# Already in Cargo.lock through eframe; same version so nothing is built twice.
ron = "0.12"
[build-dependencies]
winresource = "0.1"
@@ -50,6 +53,9 @@ muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
winit = { version = "0.30", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
+93 -20
View File
@@ -86,7 +86,7 @@ impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
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.cad.guarded_logic(ctx);
// 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
@@ -116,7 +116,7 @@ impl eframe::App for App {
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.cad.guarded_ui(ui);
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
}
@@ -127,7 +127,8 @@ impl eframe::App for App {
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
/// (`persist_window`); egui state is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
@@ -194,8 +195,51 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters
}
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
/// state. An unreadable or malformed file counts as none, as it does for eframe.
fn saved_window(app_ron: &str) -> bool {
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
}
/// The window state from the last session is saved and will be restored (#2595).
fn restores_window() -> bool {
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
}
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
/// saved by the last session is restored instead of opening centred.
fn window_options(restoring: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID),
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
// restored window on the monitors that are connected now.
persist_window: true,
// The first launch opens centred on its screen, not cascaded from the top-left corner
// (#1129); later launches go back where the window was (eframe would centre over that).
// Wayland compositors place windows themselves.
centered: !restoring,
// 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()
}
}
const APP_ID: &str = "ai.storyteller.cadcraft";
/// The key eframe saves the main window state under in its storage.
const WINDOW_KEY: &str = "window";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
const STENCIL_BITS: u8 = 8;
@@ -214,6 +258,11 @@ fn version_string() -> String {
/// 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 {
// A copy of this program started to read or write a DWG file does only that.
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
std::process::exit(code);
}
cadcraft_io::dwg_worker::use_own_worker();
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
// The update helper relaunches this binary after a self-update; it runs and exits here.
@@ -245,6 +294,9 @@ fn main() -> eframe::Result {
}
install_io();
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
if let Some(dir) = &vars.config_dir {
cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR));
}
if let Some(name) = &vars.font_alt {
cadcraft_engine::fonts::ttf::set_font_alt(name);
}
@@ -256,22 +308,7 @@ fn main() -> eframe::Result {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.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()
..window_options(restores_window())
};
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
@@ -300,6 +337,16 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
// close request for the window instead, so it asks first, as the close button does.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_close_requested_on_terminate(true);
}));
}
eframe::run_native(
"CADCraft",
options,
@@ -383,7 +430,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{filters_for_save, follow_desktop};
use super::{filters_for_save, follow_desktop, saved_window, window_options};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -401,6 +448,32 @@ mod tests {
}
}
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
#[test]
fn the_first_launch_opens_centred() {
let o = window_options(false);
assert!(o.centered, "centred on launch");
assert!(o.persist_window, "the window state is saved for the next launch");
}
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
#[test]
fn a_saved_window_reopens_where_it_was() {
let o = window_options(true);
assert!(!o.centered, "centring would move a restored window");
assert!(o.persist_window);
}
/// Only a readable `app.ron` with a window entry counts as a saved window state.
#[test]
fn a_saved_window_is_read_from_app_ron() {
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
assert!(!saved_window(""));
assert!(!saved_window("{\"window\": "), "truncated");
assert!(!saved_window("not ron at all"));
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
+15 -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"
@@ -99,3 +96,16 @@ sync_github_name = false
[people.dylanmtaylor]
sync_github_name = true
[people.GULERTOLGA]
sync_github_name = true
[people.Tyler-Chiriboga]
real_name = "Tyler Chiriboga"
sync_github_name = true
[people.starise]
real_name = "Andrea Brandi"
[people.qinshah]
sync_github_name = true
+36 -8
View File
@@ -11,16 +11,13 @@ use std::collections::HashSet;
use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
use crate::model::{Model, Prep};
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000;
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build_budgeted(d, &[h], &mut members_left);
let m = Model::build_with(d, &[h], &mut prep);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -237,5 +234,36 @@ mod budget_tests {
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
// Solving it is refused for the same reason, before any Jacobian is built.
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
}
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
/// and the solve is refused by its rebuild budget instead of running.
#[test]
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
use cadcraft_doc::{Constraint, Line};
use cadcraft_geom::Vec3;
let mut d = Drawing::default();
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
let l =
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
for id in 1..=200 {
d.constraints.push(Constraint {
id,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
name: String::new(),
expr: String::new(),
});
}
let cons: Vec<&Constraint> = d.constraints.iter().collect();
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
assert!(skipped.is_empty());
let comp = comps.first().unwrap();
assert_eq!(comp.built.len(), 200);
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
assert!(crate::purge_invalid(&mut d).is_empty());
}
}
+26 -2
View File
@@ -284,7 +284,14 @@ impl Env {
pub fn new() -> Self {
Env::default()
}
/// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so
/// only a bounded prefix of it is kept: it still fails (as too long), without the environment
/// holding or copying the whole text.
pub fn insert(&mut self, name: &str, expr: &str) {
let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) {
Some(end) => expr.get(..end).unwrap_or(expr),
None => expr,
};
self.exprs.insert(name.to_string(), expr.to_string());
self.cache.clear();
}
@@ -309,8 +316,9 @@ impl Env {
if stack.len() >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?;
// Parse the stored text in place: `parse` refuses an oversized expression before reading
// it, so a long imported expression referenced many times is never copied (CWE-400).
let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?;
stack.push(name.to_string());
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
stack.pop();
@@ -400,6 +408,22 @@ mod tests {
.unwrap();
}
/// An oversized named expression is kept only as a bounded prefix and still fails, however
/// often it is referenced.
#[test]
fn oversized_named_expression_is_bounded_and_fails() {
let mut env = Env::new();
let long = format!("1+{}", "é".repeat(500_000));
env.insert("big", &long);
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
for _ in 0..200 {
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
}
env.insert("ok", "1+1+1");
assert_eq!(env.value("ok"), Ok(3.0));
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+74 -11
View File
@@ -30,7 +30,7 @@ mod lm;
mod model;
mod residual;
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
use cadcraft_geom::Vec2;
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
pub use expr::{Env, ExprError};
pub use model::{ref_points, rigid_points, supported};
use model::Model;
use model::{Model, Prep};
use residual::{Built, Form};
/// Most constraints one drawing may hold.
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
const MAX_CONFLICT_PROBES: usize = 48;
/// Solver iterations.
const MAX_ITER: usize = 200;
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
/// shared by solving, redundancy checks and DOF analysis.
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
const TRIAL_EVALS: f64 = 14.0;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum SolveError {
@@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool {
d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name)
}
/// Highest numbered default name [`next_name`] tries.
const MAX_NAME_SUFFIX: u32 = 100_000;
/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …).
pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String {
let prefix = kind.name_prefix();
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
// Collect the suffixes in use in one pass, then take the first free one: checking every
// candidate against every name is quadratic in the number of names (CWE-407). Only a
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
let taken: HashSet<u32> = d
.parametric
.parameters
.iter()
.map(|p| p.name.as_str())
.chain(d.constraints.iter().map(|c| c.name.as_str()))
.filter_map(|n| n.strip_prefix(prefix))
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
.filter_map(|s| s.parse::<u32>().ok())
.collect();
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
}
/// Next free constraint id.
@@ -251,12 +272,41 @@ impl Comp {
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
}
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
/// are shared by residuals at the same points, see [`Model`]).
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
let rows = self.rows(skip) as f64;
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
let mut splines: Vec<Handle> = Vec::new();
for (i, b) in self.built.iter().enumerate() {
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
}
}
splines.sort_unstable();
splines.dedup();
let rebuilds: f64 = splines
.iter()
.filter_map(|h| self.model.slot(*h))
.map(|s| {
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
})
.sum();
evals * rows + rebuilds
}
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
let free = self.free(skip);
let rows = self.rows(skip);
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
return Err(SolveError::TooLarge);
}
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
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
@@ -410,24 +460,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
/// 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;
build_budgeted(d, cons, opts, &mut members_left)
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
/// its member budget, spline rebuilds) between every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
pub(crate) fn build_with(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
prep: &mut Prep,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d);
let mut skipped = Vec::new();
let mut comps = Vec::new();
for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build_budgeted(d, &handles, members_left);
let mut model = Model::build_with(d, &handles, prep);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
@@ -640,6 +690,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
/// Check that a constraint's references make sense for its type.
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
for h in &handles {
match d.entity(*h) {
@@ -648,7 +703,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
_ => {}
}
}
let mut model = Model::build(d, &handles);
let mut model = Model::build_with(d, &handles, prep);
interpret(c, &mut model, 1.0).map(|_| ())
}
@@ -665,7 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
/// Returns the removed ids.
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
let mut gone = Vec::new();
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
// One preparation for every check: each block is sampled and each spline rebuilt once,
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
// a constraint is never dropped because earlier ones spent it: every block is still
// examined at most once, up to its own member limit.
let mut prep = Prep::new(usize::MAX);
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
let mut i = 0;
d.constraints.retain(|c| {
let k = keep.get(i).copied().unwrap_or(true);
@@ -752,6 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
{
let before = comp.free(Some(pos));
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let after = comp.free(None);
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
let rows_old = comp.rows(Some(pos));
+193 -62
View File
@@ -14,9 +14,11 @@
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
//! a tangency between curves); they are never written back.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
use crate::curves::{Ell, Frame, clamped, spline_end};
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
pub len: usize,
}
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
#[derive(Clone, Debug, Default)]
pub(crate) struct Model {
pub slots: Vec<Slot>,
@@ -79,20 +84,75 @@ pub(crate) struct Model {
splines: HashMap<Handle, GSpline>,
/// Block reference rigid points relative to the insertion point at rotation 0.
rigid: HashMap<Handle, Vec<Vec2>>,
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
/// same spline at the same defining points (several constraints on one spline, or derivatives
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
rebuilt: Rc<RefCell<SplineCache>>,
}
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
#[derive(Debug)]
pub(crate) struct Prep {
/// Block members that may still be examined across the operation.
members_left: usize,
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
folded: Option<HashMap<String, String>>,
rebuilt: Rc<RefCell<SplineCache>>,
}
impl Prep {
/// Preparation that may examine at most `members` block members in all.
pub(crate) fn new(members: usize) -> Prep {
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
}
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
if let Some(b) = d.blocks.get(name) {
return Some(b);
}
let folded = self.folded.get_or_insert_with(|| {
let mut m = HashMap::with_capacity(d.blocks.len());
for k in d.blocks.keys() {
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
}
m
});
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
}
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
if let Some(s) = self.blocks.get(name) {
return s.clone();
}
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
self.blocks.insert(name.to_string(), sampled.clone());
sampled
}
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
}
}
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
let n = n as f64;
if fit { n * n * n } else { n * n }
}
}
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
/// Sample a block's finite characteristic points in block coordinates, within the per-block
/// member limit and the caller-shared `members_left`. Members that contribute no points still
/// consume traversal work; nested blocks are not entered.
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
// points (Text, Ray, …) still cost traversal (CWE-407).
for (examined, e) in block.entities.iter().enumerate() {
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
break;
}
*members_left -= 1;
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
_ => Vec::new(),
};
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
out.extend(pts.into_iter().filter(|p| p.is_finite()));
}
out.truncate(MAX_RIGID_POINTS);
out
out.into()
}
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
}
/// 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> {
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
@@ -243,15 +297,22 @@ impl Model {
// 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();
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
// weights default to 1 in eval and only excess weights are unused; a control
// spline's fit points are dropped when its control points move (write_back), and
// a fit spline is rebuilt from its parameters, not from its old control net.
let mut kept = if fit {
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
} else {
GSpline { fit: Vec::new(), ..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();
let pts: Vec<Vec2> = prep.rigid_points(d, ins).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);
@@ -262,16 +323,17 @@ impl Model {
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();
/// Build solver parameters for supported entities, sharing block sampling, its member budget
/// and spline rebuilds with every other model built from `prep`.
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
for &h in handles {
if m.index.contains_key(&h) {
continue;
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) 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;
@@ -373,18 +435,20 @@ impl Model {
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
}
/// The spline of a reference with the defining points taken from `x`.
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
let Shape::Spline { n, fit, .. } = s.shape else { return None };
let t = self.splines.get(&s.handle)?;
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
Some(if fit {
t.refit(&pts, t.closed)
} else {
let mut sp = t.clone();
sp.control = pts;
sp
})
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
&& *at == pts
{
return Some(Rc::clone(sp));
}
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
Some(sp)
}
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
@@ -421,7 +485,7 @@ impl Model {
let e = self.ell_at(x, o, true)?;
e.frame(if at_end { e.t1 } else { e.t0 })
}
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
_ => None,
}
}
@@ -590,7 +654,8 @@ impl Model {
}
let shape = s.shape;
// A control-point spline loses its fit data once its control points move.
let spline = self.spline(x, s).map(|mut sp| {
let spline = self.spline(x, s).map(|sp| {
let mut sp = GSpline::clone(&sp);
if matches!(shape, Shape::Spline { fit: false, .. }) {
sp.fit.clear();
}
@@ -712,21 +777,21 @@ mod budget_tests {
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
let mut block = Block::new(name);
for _ in 0..lines {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
d.blocks.insert(name.into(), Arc::new(block));
}
fn insert(block: &str) -> Insert {
Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
@@ -735,13 +800,79 @@ mod budget_tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
}
}
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
}
/// The member budget is shared by every model of one preparation, and a block it already
/// sampled is reused by later Inserts without examining its members again.
#[test]
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
let mut d = Drawing::default();
add_block(&mut d, "BAR", 3);
add_block(&mut d, "BAZ", 3);
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
let mut prep = Prep::new(2);
let first = Model::build_with(&d, &[bar], &mut prep);
assert_eq!(first.rigid[&bar].len(), 4);
assert_eq!(prep.members_left, 0);
let other = Model::build_with(&d, &[baz], &mut prep);
assert!(other.rigid[&baz].is_empty());
let again = Model::build_with(&d, &[bar2], &mut prep);
assert_eq!(again.rigid[&bar2].len(), 4);
}
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
#[test]
fn block_names_resolve_ignoring_case() {
let mut d = Drawing::default();
add_block(&mut d, "Bar", 2);
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
let mut prep = Prep::new(MAX_RIGID_WORK);
let m = Model::build_with(&d, &[lower, missing], &mut prep);
assert_eq!(m.rigid[&lower].len(), 4);
assert!(m.rigid[&missing].is_empty());
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
}
/// A spline asked for again at the same defining points is not rebuilt, also from another
/// model of the same preparation; moved points rebuild it.
#[test]
fn spline_rebuilds_are_reused_at_the_same_points() {
let mut d = Drawing::default();
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
let mut prep = Prep::new(MAX_RIGID_WORK);
let a = Model::build_with(&d, &[h], &mut prep);
let b = Model::build_with(&d, &[h], &mut prep);
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
let first = a.spline(&a.x0, sa).unwrap();
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
let mut moved = a.x0.clone();
moved[1] += 0.5;
let other = a.spline(&moved, sa).unwrap();
assert!(!Rc::ptr_eq(&first, &other));
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
}
/// A control spline's template keeps no fit points and no excess weights.
#[test]
fn control_template_drops_unused_data() {
let mut d = Drawing::default();
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
sp.fit = vec![Vec2::ZERO; 10_000];
sp.fit_opts.tolerance = 0.5;
sp.weights = vec![1.0; 10_000];
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
let m = Model::build(&d, &[h]);
let t = &m.splines[&h];
assert!(t.fit.is_empty());
assert_eq!(t.weights.len(), 4);
}
}
+27
View File
@@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() {
}
}
}
/// The default name is the first free numbered one, found in one pass over many names in use.
#[test]
fn next_name_skips_names_in_use() {
let mut d = Drawing::default();
assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1");
for i in (1..=20_000).filter(|i| *i != 15_000) {
d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() });
}
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000");
d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() });
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001");
// A differently spelled suffix does not occupy the plain name.
let mut d = Drawing::default();
for name in ["d01", "d+1", "d1x", "D1"] {
d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() });
}
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1");
d.constraints.push(Constraint {
id: 1,
kind: ConstraintKind::Distance(DistAxis::Aligned),
refs: Vec::new(),
name: "d1".into(),
expr: "1".into(),
});
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
}
+44 -7
View File
@@ -194,7 +194,9 @@ pub struct Action {
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ActionKind {
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
/// file lists for each object, used only when the frame can't be tested (see `apply`).
Stretch {
frame: Vec<Vec2>,
#[serde(serialize_with = "hex_vertices")]
@@ -321,8 +323,9 @@ impl<'a> Cursor<'a> {
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
@@ -332,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let graph = evaluation_graph(objects, xdict)?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
@@ -341,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
/// that asks about many blocks (listing them) builds it once and passes it to
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
/// multiplies the block count by the object count (CWE-407).
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
impl<'a> ObjectIndex<'a> {
pub fn new(d: &'a Drawing) -> Self {
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
}
}
/// The extension dictionary of block `name`'s record, if it has one.
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// A block without an extension dictionary is never dynamic: answer before indexing objects.
block_xdict(d, name)?;
dynamic_block_in(d, &ObjectIndex::new(d), name)
}
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
}
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
let xdict = block_xdict(d, name)?;
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
for o in graph_members(&objects.0, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
+93 -9
View File
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
}
/// The object entry `key` (any case) of dictionary object `o` names.
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
}
/// Every entry of dictionary object `o`: (key, object).
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
@@ -403,7 +411,31 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
}
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame
/// is two corners or a short polygon; testing every point of every object against every edge of a
/// longer one would grow with their product, so the action's own vertex lists are used instead.
const MAX_FRAME_POINTS: usize = 1000;
/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the
/// action selects that lies outside its frame moves whole.
fn inside(frame: &[Vec2], points: impl Iterator<Item = Vec2>) -> Option<Vec<i32>> {
let idx: Vec<i32> = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect();
(!idx.is_empty()).then_some(idx)
}
/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than
/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps
/// the digits rounding there would drop.
fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 {
let d = b - a;
let w = p - a;
let l2 = d.len2();
let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(w - d * t).len()
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is
/// inside, for a polygon as for two corners: within a billionth of the edge's length of it.
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
match frame {
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
@@ -412,6 +444,9 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
let mut j = f.len() - 1;
for i in 0..f.len() {
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) {
return true;
}
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
inside = !inside;
}
@@ -464,7 +499,10 @@ fn apply(
_ => 0.0,
})
};
let mult = if mult == 0.0 { 1.0 } else { mult };
// A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the
// parameter's change and leaves its angle offset out (measured on a stretch action written
// with multiplier 0, angle offset 1 and group 280 = 1).
let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) };
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
@@ -473,6 +511,8 @@ fn apply(
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
// Whether the frame (two corners or a polygon) is tested point by point.
let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len());
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection {
if !handled.insert(*h) {
@@ -502,7 +542,18 @@ fn apply(
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
// The points of a line or polyline that move are those inside the frame where the
// dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It
// doesn't go by the vertex lists the action stores, which a block from another program
// can get wrong. A polyline's vertices are in its own plane: in the block's when its
// extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however
// small, moves them by as much as the elevation times the tilt, so a tilted one keeps the
// stored list.
let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) {
Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()),
Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)),
_ => vtx_by_handle.get(h).map(|i| (*i).clone()),
};
moves.entry(c).or_default().push((idx, v));
}
}
@@ -649,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
///
/// Each record is copied once at most: a key naming a record an earlier key already took is
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
/// made), so a file listing one large record under many keys would otherwise multiply its size by
/// the key count (CWE-400); AutoCAD gives every key its own record.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// Dictionary entries looked up through the index, not by scanning every object per
// dictionary (which is quadratic in the object count).
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
let mut taken: HashSet<Handle> = HashSet::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
let Some(values) = objects.get(&values) else { continue };
for (k, h) in dictionary_entries(values) {
if out.contains_key(&k) || taken.contains(&h) {
continue;
}
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
taken.insert(h);
out.insert(k, x.tags.clone());
}
}
}
@@ -1001,4 +1066,23 @@ mod tests {
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
#[test]
fn a_value_record_listed_under_many_keys_is_copied_once() {
// One large XRECORD named by many keys of a reference's value dictionary: the templates
// keep one copy, not one per key.
let mut d = Drawing::new_imperial();
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
f.objects.push(dictionary(values, cache, &entries));
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
let t = templates(&d, def_record);
assert_eq!(t.len(), 1, "one record, one template");
assert_eq!(t.get("0").map(Vec::len), Some(1000));
}
}
+102 -7
View File
@@ -48,7 +48,7 @@ mod wipeout;
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, Polyline3d, RayLine, Solid};
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
pub use fill::{Gradient, Hatch, HatchLoop};
pub use layout::{Image, Table, TableCell, Viewport};
@@ -86,6 +86,7 @@ pub enum EntityKind {
Solid(Solid),
Trace(Solid),
Face3d(Face3d),
PolyfaceMesh(PolyfaceMesh),
Region(Region),
Viewport(Viewport),
Image(Image),
@@ -140,6 +141,7 @@ impl EntityKind {
EntityKind::Solid(_) => "2D Solid",
EntityKind::Trace(_) => "Trace",
EntityKind::Face3d(_) => "3D Face",
EntityKind::PolyfaceMesh(_) => "Polyface Mesh",
EntityKind::Region(_) => "Region",
EntityKind::Viewport(_) => "Viewport",
EntityKind::Image(_) => "Raster Image",
@@ -175,6 +177,7 @@ impl EntityKind {
EntityKind::Solid(_) => "SOLID",
EntityKind::Trace(_) => "TRACE",
EntityKind::Face3d(_) => "3DFACE",
EntityKind::PolyfaceMesh(_) => "POLYLINE",
EntityKind::Region(_) => "REGION",
EntityKind::Viewport(_) => "VIEWPORT",
EntityKind::Image(_) => "IMAGE",
@@ -218,10 +221,10 @@ impl EntityKind {
let c = &s.corners;
vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
}
EntityKind::Face3d(f) => {
let c = &f.corners;
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(),
// Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn).
EntityKind::PolyfaceMesh(m) => {
m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect()
}
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
EntityKind::Hatch(h) => {
@@ -359,9 +362,13 @@ impl EntityKind {
i.scale.y *= s;
i.scale.z *= s;
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
// old one, and a MINSERT grid's columns running the other way.
if mirror {
i.scale.y = -i.scale.y;
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
i.scale.x = -i.scale.x;
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
i.col_spacing = -i.col_spacing;
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
@@ -424,6 +431,7 @@ impl EntityKind {
}
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3),
EntityKind::Region(r) => {
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
v.p = m.apply(v.p);
@@ -465,6 +473,92 @@ impl EntityKind {
}
}
/// Scale every height of the object by `f` about the height `base_z`: the Z of its points,
/// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a
/// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform`
/// already scales (text and helix heights, an insert's Z scale) are left alone; splines,
/// regions and wipeouts lie in the XY plane.
pub fn scale_heights(&mut self, base_z: f64, f: f64) {
let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f;
let z = |p: &mut Vec3| scale(&mut p.z);
let text_z = |t: &mut Text| {
z(&mut t.insert);
if let Some(a) = &mut t.align_pt {
z(a);
}
};
let attribs_z = |attribs: &mut Vec<Attrib>| {
for a in attribs {
text_z(&mut a.text);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
};
match self {
EntityKind::Line(l) => {
z(&mut l.a);
z(&mut l.b);
}
EntityKind::Point(p) => z(&mut p.p),
EntityKind::Circle(c) => z(&mut c.center),
EntityKind::Arc(a) => z(&mut a.center),
EntityKind::Ellipse(e) => z(&mut e.center),
EntityKind::LwPolyline(p) => scale(&mut p.elevation),
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z),
EntityKind::Helix(h) => z(&mut h.center),
EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base),
EntityKind::Text(t) => text_z(t),
EntityKind::AttDef(a) => {
text_z(&mut a.text);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
EntityKind::MText(t) => z(&mut t.insert),
EntityKind::Insert(i) => {
z(&mut i.insert);
attribs_z(&mut i.attribs);
}
EntityKind::Dimension(d) => {
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
z(p);
}
if let Some(j) = &mut d.marks.jog_line {
z(j);
}
if let Some(j) = &mut d.marks.jogged {
z(&mut j.center);
z(&mut j.jog);
}
d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at));
d.block = None;
}
EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)),
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
EntityKind::MLeader(ml) => {
ml.leaders.iter_mut().flatten().for_each(z);
z(&mut ml.landing);
if let Some(t) = &mut ml.text {
z(&mut t.insert);
}
if let Some(b) = &mut ml.block {
z(&mut b.insert);
attribs_z(&mut b.attribs);
}
}
EntityKind::Hatch(h) => scale(&mut h.elevation),
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
EntityKind::Viewport(v) => z(&mut v.center),
EntityKind::Image(i) => z(&mut i.insert),
EntityKind::Table(t) => z(&mut t.insert),
EntityKind::Tolerance(t) => z(&mut t.insert),
EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
}
}
/// Grip points (editable defining points), in display order.
pub fn grips(&self) -> Vec<Vec2> {
// A broken table has a grip for each part.
@@ -526,6 +620,7 @@ impl EntityKind {
}
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(),
EntityKind::Region(r) => vec![r.bounds().center()],
EntityKind::Viewport(v) => {
let c = v.center.xy();
+46
View File
@@ -69,6 +69,52 @@ pub struct Face3d {
pub hidden_edges: u8,
}
impl Face3d {
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
/// without the zero-length side of a triangle.
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
let c = self.corners;
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
}
}
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
/// that starts at that vertex).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PolyfaceMesh {
pub vertices: Vec<Vec3>,
pub faces: Vec<[i32; 4]>,
}
impl PolyfaceMesh {
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
/// index outside the vertex list are skipped.
pub fn faces3d(&self) -> Vec<Face3d> {
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
}
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
/// or one outside the vertex list.
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
let mut hidden = 0u8;
for &ix in f.iter().take_while(|&&ix| ix != 0) {
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
let p = *at.and_then(|n| self.vertices.get(n))?;
if ix < 0 {
hidden |= 1 << corners.len();
}
corners.push(p);
}
match corners.as_slice() {
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
+5
View File
@@ -52,6 +52,11 @@ pub struct Dimension {
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
/// Zero once the geometry is regenerated from the definition points.
#[serde(default)]
pub insertion_point: Vec3,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
+98 -10
View File
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
pub fill: Vec<Vec<Vec2>>,
}
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
///
/// Two linear passes: searching from every segment separately is quadratic in a run of
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
return (vec![Vec2::X; n], vec![Vec2::X; n]);
};
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
let mut cur = Vec2::X;
for k in 0..n {
let i = (last + n - k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(a) = ahead.get_mut(i) {
*a = cur;
}
}
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
for k in 0..n {
let i = (first + k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(b) = behind.get_mut(i) {
*b = cur;
}
}
(ahead, behind)
}
impl MLine {
/// A multiline through `pts` in style `def`.
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
@@ -268,16 +303,11 @@ impl MLine {
let dirs = self.directions();
let n = self.vertices.len();
let nseg = dirs.len();
// A degenerate segment takes its neighbour's direction.
let dir = |i: usize| -> Vec2 {
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
};
let dir_back = |i: usize| -> Vec2 {
(0..nseg)
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
.find(|d| *d != Vec2::ZERO)
.unwrap_or(Vec2::X)
};
// A degenerate segment takes its neighbour's direction: the first usable one at or after
// it (`ahead`), or at or before it (`behind`), wrapping around.
let (ahead, behind) = usable_directions(&dirs);
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
let end = |d: Vec2, angle: f64| {
let u = d.rotate(angle);
let s = angle.sin();
@@ -426,3 +456,61 @@ impl crate::Drawing {
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The nearest usable directions found by searching from each segment, as miters did before.
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
(ahead.collect(), behind.collect())
}
#[test]
fn usable_directions_match_a_search_from_each_segment() {
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
for dirs in [
vec![],
vec![z],
vec![a],
vec![z, z, z],
vec![a, z, z, b],
vec![z, a, z, b, z],
vec![z, z, c],
vec![c, z, z],
vec![a, b, c],
vec![z, a, z, z, b, c, z],
] {
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
}
}
#[test]
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
// Every vertex of a run on one point mitres between the segment before the run and the one after.
let style = MLineStyle::default();
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
let (expected, got) = (corner.miters(), run.miters());
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
}
#[test]
fn a_long_run_of_coincident_vertices_takes_linear_time() {
// 200 000 vertices on one point but the last: the miters must not search the run once per
// vertex (quadratic, minutes) to find the one usable direction.
let mut pts = vec![Vec2::ZERO; 200_000];
pts.push(Vec2::new(1.0, 0.0));
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
let t = std::time::Instant::now();
let miters = m.miters();
assert_eq!(miters.len(), pts.len());
assert!(miters.iter().all(|v| v.is_finite()));
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
}
+73 -7
View File
@@ -9,6 +9,9 @@ use super::HatchLoop;
/// Most loops one region has (hostile files and edits).
pub const MAX_REGION_LOOPS: usize = 10_000;
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Closed loops with bulges; `outer` false for islands.
@@ -76,20 +79,49 @@ impl Region {
}
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
/// an outer loop, odd an island.
pub fn renest(&mut self) {
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
#[must_use]
pub fn renest(&mut self) -> bool {
let polys = self.tessellate(self.fine_tol());
let depth: Vec<usize> = polys
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
// such a polygon need not be closed), so its loop is always tested.
let boxes: Vec<Option<Bounds2>> = polys
.iter()
.enumerate()
.map(|(i, l)| {
let Some(p) = probe(l) else { return 0 };
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
.map(|l| {
let b = Bounds2::from_points(l.iter().copied());
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
})
.collect();
let mut visits = 0usize;
let mut depth = Vec::with_capacity(polys.len());
for (i, l) in polys.iter().enumerate() {
let Some(p) = probe(l) else {
depth.push(0);
continue;
};
let mut d = 0;
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
if j == i || b.is_some_and(|b| !b.contains(p)) {
continue;
}
// Each loop against every other is loops x vertices: bounded so a region of many
// large nested loops cannot hold the thread (CWE-407).
visits = visits.saturating_add(o.len());
if visits > MAX_RENEST_EDGE_VISITS {
return false;
}
if point_in_polygon(o, p) {
d += 1;
}
}
depth.push(d);
}
for (l, d) in self.loops.iter_mut().zip(depth) {
l.outer = d % 2 == 0;
}
true
}
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
// `n` vertices along the bottom edge, then the other three corners.
let mut vertices: Vec<PolyVertex> =
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
HatchLoop { vertices, outer, sources: Vec::new() }
}
#[test]
fn renest_sets_depth_parity_and_skips_loops_far_away() {
// Three nested squares, and one beside them.
let mut r =
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
assert!(r.renest());
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
}
#[test]
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
// Concentric loops of many vertices each: each probe is inside the box of every larger
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
let loops = 300;
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
let before = r.clone();
assert!(!r.renest());
assert_eq!(r, before, "flags left as they were");
}
}
+81 -5
View File
@@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
/// 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)
bounds_of(d, e, depth, false, &mut HashMap::new())
}
/// 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)
bounds_of(d, e, 0, true, &mut HashMap::new())
}
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
/// The bounds of block contents measured so far, kept across the entities of one unchanged
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
/// entity would measure a block again for every reference to it, R references to a block of B
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
#[derive(Default)]
pub struct BlockBoundsCache<'a> {
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
drawn: HashMap<(&'a str, usize), Bounds2>,
/// The same with those contents counted ([`extent_bounds`]).
extents: HashMap<(&'a str, usize), Bounds2>,
}
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, depth, false, &mut cache.drawn)
}
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, 0, true, &mut cache.extents)
}
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> 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(), off_counts),
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
}
}
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
bounds_in(d, e, depth, blocks, off_counts)
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
@@ -212,6 +235,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
inner
}
};
// The block draws translated by the insertion point (group 12), so its bounds
// move with it (#2634); the cache above keeps the block's own bounds.
let off = dm.insertion_point.xy();
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
b = b.union(&inner);
}
b
@@ -438,4 +465,53 @@ mod tests {
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
}
#[test]
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
e.common.layer = layer.into();
e
};
let ins = |block: &str, at: f64| {
Entity::new(
crate::Handle(2),
EntityKind::Insert(crate::Insert {
block: block.into(),
insert: Vec3::new(at, at, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
};
let mut d = Drawing::new_imperial();
d.ensure_layer("OFF");
if let Some(l) = d.layer_mut("OFF") {
l.on = false;
}
// A block with a part on a layer turned off (left out when drawn, counted in extents),
// and a block that nests it.
let mut b = crate::Block::new("B");
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let mut n = crate::Block::new("N");
n.entities.push(ins("B", 10.0));
d.blocks.insert("N".into(), std::sync::Arc::new(n));
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
// One cache for both kinds of measure, interleaved: neither may answer for the other.
let mut cache = BlockBoundsCache::default();
for e in &ents {
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
assert!(extents.width() > drawn.width());
}
}
}
+165 -3
View File
@@ -6,7 +6,7 @@
//! named object dictionary, a table record, a block, an entity); references to objects that are
//! gone are dropped. Commands don't edit it.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use crate::{Handle, RawTag};
@@ -89,8 +89,170 @@ impl Foreign {
&& self.data_store.is_empty()
}
/// The entities the data store has records for (their 320 handles).
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
let mut out = std::collections::BTreeSet::new();
for rec in data_store_records(&self.data_store) {
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
}
}
out
}
}
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
}
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
let stored = d.foreign.data_store_entities();
let mut out = BTreeMap::new();
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let crate::EntityKind::Unknown(u) = &e.kind
&& sat_text_acis(u, e.handle, &stored)
{
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
*n = n.saturating_add(1);
}
}
out
}
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
pub fn xref_dependent(name: &str) -> bool {
name.contains('|')
}
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
/// (the section's own groups before the first record are not part of one).
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
let mut out = Vec::new();
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
while start < tags.len() {
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
out.push(tags.get(start..end).unwrap_or(&[]));
start = end;
}
out
}
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
/// groups (before the extended data) are the entity's.
pub fn has_sat_text(tags: &[RawTag]) -> bool {
let own = tags.iter().take_while(|r| r.code < 1000);
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
let mut in_modeler = !marked;
for r in own {
if r.code == 100 {
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
} else if in_modeler && (r.code == 1 || r.code == 3) {
return true;
}
}
false
}
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
/// or dropped by this handle alone, in the writer and wherever the store is read.
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
record.iter().find(|t| t.code == 320)
}
impl Foreign {
/// The kept objects by handle (the first of any that share one).
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
let mut m = BTreeMap::new();
for o in &self.objects {
m.entry(o.handle).or_insert(o);
}
m
}
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
/// and child pointers, groups 350 and 360), `root` first.
pub fn tree(&self, root: Handle) -> Vec<Handle> {
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
// their product.
let by_handle = self.object_index();
let mut out = vec![root];
let mut seen = BTreeSet::from([root]);
let mut i = 0;
while let Some(&h) = out.get(i) {
i += 1;
let Some(o) = by_handle.get(&h) else { continue };
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
out.push(c);
}
}
}
out
}
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
/// root's owner. References to objects outside the tree become null. Fields keep a field
/// list: this one gets a copy of the source's if it has none.
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
if fresh.len() < tree.len() + 2 {
return;
}
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
let by_handle = src.object_index();
let mut kinds = Vec::new();
for (n, h) in tree.iter().enumerate() {
let Some(o) = by_handle.get(h) else { continue };
let mut c = (*o).clone();
let Some(&new) = fresh.get(n) else { continue };
c.handle = new;
for t in &mut c.tags {
if t.code == 5 {
t.value = new.hex();
} else if matches!(t.code, 320..=369 | 390..=399) {
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
}
}
kinds.push(c.kind.clone());
self.objects.push(c);
}
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
let mut c = list.clone();
c.handle = h;
for t in &mut c.tags {
match t.code {
5 => t.value = h.hex(),
330 => t.value = "0".to_string(),
_ => {}
}
}
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
t.value = nod.hex();
}
self.targets.insert(nod, ("NOD".to_string(), String::new()));
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
kinds.push(c.kind.clone());
self.objects.push(c);
}
for k in kinds {
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
if !self.classes.iter().any(named) {
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
}
}
}
}
+10 -2
View File
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
@@ -132,6 +132,10 @@ pub struct Drawing {
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
/// drawing reopens with it. Never set on an open drawing.
pub portable_fonts: bool,
}
impl Default for Drawing {
@@ -191,6 +195,7 @@ impl Drawing {
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
}
}
@@ -378,10 +383,13 @@ impl Drawing {
/// 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;
// One block-bounds cache for the whole space: every reference to a block reuses its
// measured contents instead of measuring the definition again.
let mut cache = BlockBoundsCache::default();
if let Some(store) = self.space(space) {
for e in store.iter() {
if self.in_extents(e) {
b = b.union(&extent_bounds(self, e));
b = b.union(&extent_bounds_cached(self, e, &mut cache));
}
}
}
+35
View File
@@ -189,6 +189,19 @@ impl EntityStore {
None => false,
}
}
/// The objects directly under and above `h` in the draw order (None at the bottom or the
/// top), when `h` is here.
pub fn neighbours(&self, h: Handle) -> Option<(Option<Handle>, Option<Handle>)> {
let pos = self.chunk_pos(self.chunk_id(h)?)?;
let items = &self.chunks.get(pos)?.items;
let i = items.iter().position(|e| e.handle == h)?;
let below = match i.checked_sub(1) {
Some(j) => items.get(j),
None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()),
};
let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first()));
Some((below.map(|e| e.handle), above.map(|e| e.handle)))
}
/// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing
/// moves) when either is absent or they are the same object.
pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool {
@@ -364,4 +377,26 @@ mod tests {
assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));
}
}
#[test]
fn neighbours_across_chunks() {
let mut s = EntityStore::new();
for h in 1..=3000 {
s.push(ent(h));
}
assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501)))));
// The first and last objects of a chunk look into the chunks next to it.
let k = CHUNK_MAX as u64;
assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1)))));
assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None)));
assert_eq!(s.neighbours(Handle(9999)), None);
assert!(s.move_next_to(Handle(3000), Handle(1), false));
assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1)))));
let mut one = EntityStore::new();
one.push(ent(7));
assert_eq!(one.neighbours(Handle(7)), Some((None, None)));
}
}
+10 -1
View File
@@ -799,6 +799,10 @@ pub struct PageSetup {
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
#[serde(skip_serializing_if = "Option::is_none")]
pub image_origin: Option<Vec2>,
}
impl Default for PageSetup {
@@ -820,6 +824,7 @@ impl Default for PageSetup {
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
image_origin: None,
}
}
}
@@ -835,11 +840,15 @@ pub struct Layout {
/// Saved paper-space view (centre, height).
#[serde(default)]
pub view: Option<(Vec2, f64)>,
/// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had
/// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<(Vec2, Vec2)>,
}
impl Layout {
pub fn new(name: &str, tab_order: u32) -> Self {
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }
}
}
+39
View File
@@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
}
/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and
/// the move in the insertion point, and the block draws translated by it. Extents follow the
/// drawing, not the stale block contents (#2634).
#[test]
fn dimension_insertion_point_moves_its_block_in_extents() {
let moved = |ins: Vec3| {
let mut d = Drawing::new_metric();
let mut blk = Block::new("*D1");
blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
let dm = Dimension {
kind: DimKind::Aligned,
defpt: Vec3::new(5.0, 1.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
insertion_point: ins,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
d
};
// Without the offset the block's far-away contents reach the extents.
assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0);
// With it, the block draws at the definition points and the extents stay at the drawing.
assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.0);
}
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
/// entity inside a block, and for a block reference itself.
@@ -637,6 +675,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
overrides: Default::default(),
assoc: vec![],
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
)
.unwrap();
@@ -0,0 +1,19 @@
//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to
//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn.
use cadcraft_doc::{EntityKind, Face3d, Prim};
use cadcraft_geom::Vec3;
fn sides(f: Face3d) -> usize {
EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count()
}
#[test]
fn hidden_edges_are_not_drawn() {
let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)];
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0);
let triangle = [quad[0], quad[1], quad[2], quad[2]];
assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3);
}
+1
View File
@@ -1,2 +1,3 @@
mod face3d_hidden_edges;
mod lin_pat_parse;
mod sub_segment_alias;
+8 -2
View File
@@ -8,6 +8,12 @@ license.workspace = true
[lints]
workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
[dependencies]
log = { workspace = true }
# 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"
acadrust = "0.6.6"
+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");
}
}
+348 -46
View File
@@ -19,10 +19,8 @@
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
//! table or section can still make the dependency allocate before any size is known, so the app
//! runs both directions in a child process ([`worker`]): what that kills is the child.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -58,13 +56,18 @@ 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))]
pub mod worker;
#[cfg(not(no_dwg_bridge))]
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
use std::io::{Cursor, Write};
use std::panic::AssertUnwindSafe;
@@ -110,8 +113,11 @@ mod native {
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
}
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
let written = std::panic::catch_unwind(AssertUnwindSafe(|| {
acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?;
mtext_contents(&doc, &mut out)
}))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
if out.exceeded {
return Err(format!(
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
@@ -124,6 +130,94 @@ mod native {
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already
/// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents
/// from the native values instead, using its lossless string writer. All other groups,
/// including embedded-object data, remain byte-for-byte as the DXF writer emitted them.
fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> {
use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter};
let contents: std::collections::HashMap<_, _> = doc
.entities()
.filter_map(|e| match e {
acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())),
_ => None,
})
.collect();
if contents.is_empty() {
return Ok(());
}
let dxf = std::mem::take(&mut out.buf);
let mut lines = dxf.split_inclusive(|b| *b == b'\n');
let (mut mtext, mut text, mut written) = (false, None, false);
while let Some(code) = lines.next() {
let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?;
match code.trim_ascii() {
b"0" => {
mtext = value.trim_ascii() == b"MTEXT";
text = None;
written = false;
}
b"5" if mtext => {
text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h));
}
b"101" => mtext = false,
b"1" | b"3" if mtext && text.is_some() => {
if let Some(&text) = text
&& !written
{
let mut writer = DxfTextWriter::new(&mut *out);
if doc.version < acadrust::DxfVersion::AC1021
&& let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page)
{
writer.set_encoding(encoding);
}
// Each character can expand to two caret-escape characters. Keep
// those pairs together and each group within 250 characters.
let mut rest = text;
while let Some((end, _)) = rest.char_indices().nth(125) {
let (chunk, tail) = rest.split_at(end);
writer.write_xrecord_string(3, chunk)?;
rest = tail;
}
writer.write_xrecord_string(1, rest)?;
written = true;
}
continue;
}
_ => {}
}
out.write_all(code)?;
out.write_all(value)?;
}
Ok(())
}
#[cfg(test)]
#[test]
fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> {
for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] {
let mut doc = acadrust::CadDocument::new();
doc.version = version;
doc.header.code_page = "ANSI_1252".into();
let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO);
t.common.handle = acadrust::Handle::new(0x10);
doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?;
// Only the MTEXT's own 1/3 groups change. The embedded object's groups and the
// following TEXT record must survive byte-for-byte, including their spaces.
let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n";
let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n";
let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat();
let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false };
mtext_contents(&doc, &mut out)?;
let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() };
assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat());
let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false };
assert!(mtext_contents(&doc, &mut limited).is_err());
assert!(limited.exceeded);
}
Ok(())
}
/// Most record types named in the skipped-records note.
const NOTE_TYPES: usize = 6;
@@ -241,12 +335,51 @@ mod native {
/// can cover rows × cols cells, so the total is bounded too.
const MAX_MERGE_CELLS: usize = 1 << 24;
/// A table cell's own text height only when the file says it overrides the table style's.
/// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a
/// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an
/// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the
/// content's format override bit 128; without either the height is cleared (0 is no
/// override), so the table style's height applies.
fn table_cell_heights(doc: &mut acadrust::CadDocument) {
use acadrust::entities::table::CellStylePropertyFlags;
const CONTENT_TEXT_HEIGHT: i32 = 128;
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
// The first content's own height (group 144) when its format override sets bit 128.
// CADCraft keeps one height for the cell's text, so a later content's override
// must not stand in for the first content's.
let content_height = cell
.contents
.first()
.filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0)
.map(|c| c.text_height)
.filter(|h| h.is_finite() && *h > 0.0);
if let Some(h) = content_height {
// CADCraft reads only the style's height (group 140), so carry it there.
cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h;
continue;
}
// A style overrides through its property flags or, in newer files, its override
// flags; anything else is the writer's default and is cleared.
if let Some(style) = cell.style.as_mut()
&& !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT)
&& style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0
{
style.text_height = 0.0;
}
}
}
}
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
/// components were dropped. The document comes from a third-party reader and is hostile.
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
super::fail_in_test("post_process");
current_style_names(doc);
table_merges(doc);
table_cell_heights(doc);
table_style_rows(doc, false);
keep_sort_handles(doc);
drop_orphaned_dgn_components(doc)
@@ -344,7 +477,7 @@ mod native {
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// acadrust 0.6.5 reads and writes itself.
/// acadrust 0.6.6 reads and writes itself.
fn dimension_flags(doc: &mut acadrust::CadDocument) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
@@ -411,15 +544,22 @@ mod native {
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
/// records of each DXF type were left out, so a save can say so.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
let version = doc.version;
let mut dropped = super::Unwritten::new();
let mut count = |name: &str| {
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
*n = n.saturating_add(1);
};
let unwritable: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Unknown(u)
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(&u.dxf_name);
Some(u.common.handle)
}
_ => None,
@@ -432,9 +572,10 @@ mod native {
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(type_name);
Some(*h)
}
_ => None,
@@ -443,7 +584,7 @@ mod native {
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
dropped
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
@@ -511,12 +652,19 @@ mod native {
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
/// (AC1018).
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
convert(dxf, None)
convert(dxf, None).map(|(dwg, _)| dwg)
}
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
/// whatever version the DXF declares.
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
let v = match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
@@ -574,7 +722,7 @@ mod native {
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), 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
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
@@ -599,21 +747,23 @@ mod native {
None => {}
}
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
guarded(AssertUnwindSafe(|| {
let unwritten = 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);
let unwritten = drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
current_layer(&mut doc);
unwritten
}))
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
let dwg = 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}"))
.map_err(|e| format!("DWG write: {e}"))?;
Ok((dwg, unwritten))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
@@ -730,6 +880,34 @@ mod native {
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
}
/// A height marked only in the style's override flags (as newer files store it) is kept,
/// and the same height with no flag at all is cleared (review on #2588). Tested on the
/// read document because acadrust's DWG writer doesn't write the override flags.
#[test]
fn a_style_override_flag_keeps_the_cell_height() {
use acadrust::entities::table::{CellStyle, CellStylePropertyFlags};
let height = |override_flags: i32| {
let mut doc = doc_with_table(1, 1, 0);
for e in doc.entities_mut() {
if let EntityType::Table(t) = e {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
let mut s = CellStyle::new();
s.text_height = 0.5;
s.override_flags = override_flags;
cell.style = Some(s);
}
}
}
table_cell_heights(&mut doc);
doc.entities().find_map(|e| match e {
EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height),
_ => None,
})
};
assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5));
assert_eq!(height(0), Some(0.0));
}
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
#[test]
fn a_panic_in_post_processing_is_an_error() {
@@ -741,18 +919,22 @@ mod native {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
#[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, dxf_to_dwg_noted};
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
/// can't write.
pub type Unwritten = std::collections::BTreeMap<String, usize>;
// 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(target_arch = "wasm32")))]
#[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(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[allow(clippy::panic)]
fn fail_in_test(_stage: &str) {
#[cfg(test)]
@@ -762,31 +944,61 @@ fn fail_in_test(_stage: &str) {
}
/// 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" };
/// There's no worker without a bridge.
#[cfg(no_dwg_bridge)]
pub const WORKER_ARG: &str = "--dwg-worker";
#[cfg(no_dwg_bridge)]
pub fn serve_if_asked() -> Option<i32> {
None
}
#[cfg(no_dwg_bridge)]
pub fn use_own_worker() {}
#[cfg(no_dwg_bridge)]
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
dwg_to_dxf_noted(bytes, limits)
}
#[cfg(no_dwg_bridge)]
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_as(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
dxf_to_dwg_noted(dxf, version)
}
#[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(no_dwg_bridge)]
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(test)]
@@ -794,14 +1006,14 @@ mod tests {
use super::*;
/// The DXF of an empty drawing, enough for the bridge to make a small DWG.
#[cfg(not(target_arch = "wasm32"))]
#[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(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
struct PanicIn;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
impl PanicIn {
fn at(stage: &'static str) -> Self {
PANIC_IN.with(|p| p.set(Some(stage)));
@@ -809,7 +1021,7 @@ mod tests {
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
impl Drop for PanicIn {
fn drop(&mut self) {
PANIC_IN.with(|p| p.set(None));
@@ -818,7 +1030,7 @@ mod tests {
/// 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(target_arch = "wasm32"))]
#[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();
@@ -829,7 +1041,7 @@ mod tests {
}
/// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402).
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(no_dwg_bridge))]
#[test]
fn converting_to_dwg_survives_a_panic_in_the_fix_ups() {
let _panic = PanicIn::at("convert");
@@ -846,7 +1058,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";
@@ -866,7 +1078,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
@@ -876,7 +1088,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 {
@@ -893,7 +1105,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])];
@@ -940,7 +1152,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
@@ -957,7 +1169,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::*;
@@ -1072,4 +1284,94 @@ mod tests {
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
/// from DXF is written whole, with the byte AutoCAD writes (0).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
use acadrust::objects::*;
let scale_action = |doc: &acadrust::CadDocument| {
doc.objects.values().find_map(|o| match o {
ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()),
_ => None,
})
};
let mut doc = acadrust::CadDocument::new();
let h = doc.allocate_handle();
let conn = |code, name: &str| BlockConnection { code, name: name.into() };
let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction");
action.handle = h;
action.owner = doc.header.named_objects_dict_handle;
let mut a = BlockBasePointAction::default();
a.action.action.element.name = "Scale".into();
a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")];
a.action.dependent = true;
a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")];
a.dwg_tail_byte = 7;
action.data = DynamicBlockData::ScaleAction(a.clone());
doc.objects.insert(h, ObjectType::DynamicBlock(action));
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written");
let mut dxf = Vec::new();
acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap();
for version in ["AC1018", "AC1032"] {
let dwg = dxf_to_dwg_as(&dxf, version).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written"));
assert_eq!(read.connections, a.connections, "{version}");
assert_eq!(read.action.connections, a.action.connections, "{version}");
assert_eq!(read.dwg_tail_byte, 0, "{version}");
}
}
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
/// keeps whole reports nothing.
#[cfg(not(no_dwg_bridge))]
#[test]
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
let dxf = |body: &str| {
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
};
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
assert!(is_dwg(&dwg), "{version}");
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
}
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
assert!(unwritten.is_empty(), "{unwritten:?}");
}
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
/// the DWG with its data: it isn't in the list of what the writer left out.
#[cfg(not(no_dwg_bridge))]
#[test]
fn ole_frames_are_written_with_their_data() {
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
let dxf =
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let frames: Vec<Vec<u8>> = back
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
_ => None,
})
.collect();
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! The DWG reader and writer in a child process.
//!
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
//! that reads one request from standard input and writes one reply to standard output. A file that
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
//!
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
//!
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
//! untrusted input.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 1;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Most bytes accepted for what a reply carries besides the drawing.
const MAX_NOTES_BYTES: u64 = 64 << 20;
/// Most bytes accepted as a written DWG.
const MAX_DWG_REPLY: u64 = 2 << 30;
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
const MAX_REQUEST: u64 = (2 << 30) + 256;
struct Worker {
program: PathBuf,
args: Vec<OsString>,
}
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
if let Ok(mut w) = WORKER.lock() {
*w = Some(Worker { program, args });
}
}
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
/// found, the reader and writer stay in this process.
pub fn use_own_worker() {
match std::env::current_exe() {
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
}
}
/// Serve one request and return the exit code if this process was started as a worker.
pub fn serve_if_asked() -> Option<i32> {
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
}
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
// Before starting a process, and so the same limit holds with or without one.
if bytes.len() > limits.max_dwg_bytes {
return super::dwg_to_dxf_noted(bytes, limits);
}
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
Some(reply) => reply?,
None => super::dwg_to_dxf_noted(bytes, limits),
}
}
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
fn unwritten_from(notes: &[String]) -> super::Unwritten {
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
super::Unwritten::new(),
|mut m, (kind, n)| {
*m.entry(kind).or_insert(0) = n;
m
},
)
}
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
#[doc(hidden)]
pub fn crash_worker_for_test() -> Result<(), String> {
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
}
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
let mut r = vec![PROTOCOL, op];
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
r.push(u8::try_from(version.len()).unwrap_or(0));
r.extend(version.as_bytes());
r
}
type Reply = Result<(Vec<u8>, Vec<String>), String>;
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
let mut command = {
let worker = WORKER.lock().ok()?;
let worker = worker.as_ref()?;
let mut c = Command::new(&worker.program);
c.args(&worker.args);
c
};
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
log::warn!("DWG worker: can't start it: {e}");
return None;
}
};
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let received = std::thread::scope(|scope| {
// Written beside the read: a worker that replies before it has read everything, or dies,
// must block neither side. A failed write shows up as the reply going wrong.
scope.spawn(move || {
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
});
let (tx, rx) = channel();
scope.spawn(move || {
let mut reply = Vec::new();
let read = from_child.take(reply_limit).read_to_end(&mut reply);
let _ = tx.send(read.map(|_| reply));
});
let received = rx.recv_timeout(TIMEOUT);
if matches!(received, Err(RecvTimeoutError::Timeout)) {
let _ = child.kill();
}
received
});
let status = child.wait();
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
Some(match (received, status) {
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
_ => Err(stopped()),
})
}
/// Take `n` bytes off the front of `b`.
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, tail) = b.split_at_checked(n)?;
*b = tail;
Some(head)
}
fn take_u32(b: &mut &[u8]) -> Option<usize> {
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
usize::try_from(u32::from_le_bytes(bytes)).ok()
}
fn take_u64(b: &mut &[u8]) -> Option<u64> {
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
}
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
/// of the memory the app may use: they are kept, not copied.
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
let total = reply.len();
let mut b = &reply[..];
match *take(&mut b, 1)?.first()? {
0 => {
let count = take_u32(&mut b)?;
let mut notes = Vec::new();
for _ in 0..count {
let n = take_u32(&mut b)?;
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
}
let n = usize::try_from(take_u64(&mut b)?).ok()?;
if b.len() != n {
return None;
}
reply.drain(..total - n);
Some(Ok((reply, notes)))
}
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
_ => None,
}
}
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
match reply {
Ok((bytes, notes)) => {
out.write_all(&[0])?;
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
for note in &notes {
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
if u32::try_from(note.len()).is_ok() {
out.write_all(note.as_bytes())?;
}
}
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
out.write_all(&bytes)?;
}
Err(e) => {
out.write_all(&[1])?;
out.write_all(e.as_bytes())?;
}
}
out.flush()
}
/// What a request asks for, or why it can't be served.
fn handle(mut b: &[u8]) -> Reply {
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
return Err(bad());
}
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
CRASH => std::process::abort(),
_ => Err(bad()),
}
}
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
fn serve(input: impl Read, mut output: impl Write) -> i32 {
let mut request = Vec::new();
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
Ok(_) => handle(&request),
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
};
i32::from(write_reply(&mut output, reply).is_err())
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(reply: Reply) -> Vec<u8> {
let mut out = Vec::new();
write_reply(&mut out, reply).unwrap();
out
}
#[test]
fn what_the_writer_left_out_survives_the_reply() {
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
assert_eq!(unwritten_from(&notes), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
}
#[test]
fn replies_round_trip() {
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
let failed: Reply = Err("DWG: no".into());
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
}
#[test]
fn a_reply_cut_short_or_made_up_is_refused() {
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
for n in 0..whole.len() {
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
}
assert_eq!(parse_reply(vec![7, 0, 0]), None);
// A note count far beyond what follows ends at the first missing note.
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
}
#[test]
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
for request in [
&[][..],
&[PROTOCOL + 1, DECODE],
&[PROTOCOL],
&[PROTOCOL, DECODE, 1, 2, 3],
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
] {
assert!(handle(request).is_err(), "{request:?}");
}
}
#[test]
fn serving_reports_a_decode_failure_as_a_reply() {
let mut request = self::request(DECODE, Limits::DEFAULT, "");
request.extend(b"AC1032 not really a drawing");
let mut out = Vec::new();
assert_eq!(serve(&request[..], &mut out), 0);
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
}
}
+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 {
+142 -30
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;
@@ -22,8 +23,11 @@ pub enum DxfError {
Eof,
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
/// limit was reached), `limit` the maximum.
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
TooLarge { tags: usize, limit: usize },
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
OutOfMemory { tags: usize },
}
pub type Result<T> = std::result::Result<T, DxfError>;
@@ -162,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value {
}
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
pub const MAX_TAGS: usize = 50_000_000;
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
/// raise or lower it.
pub const MAX_TAGS: usize = 100_000_000;
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
/// [`MAX_TAGS`].
pub fn max_tags() -> usize {
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
}
fn max_tags_from(v: Option<&str>) -> usize {
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
}
mod codepage;
pub use codepage::decode_code_page;
/// Parse a DXF file (ASCII or binary).
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
parse_with_limit(bytes, MAX_TAGS)
parse_with_limit(bytes, max_tags())
}
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
@@ -198,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags)
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
}
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
@@ -270,12 +290,17 @@ fn unescape_unicode(s: &str) -> String {
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_with_limit(text, MAX_TAGS)
parse_ascii_counted(text, max_tags(), 0)
}
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
/// with an allocation that can fail: where the system refuses that much memory, opening the file
/// is an error instead of the end of the app. Growing the list instead could also briefly take
/// half as much again.
#[allow(clippy::while_let_loop)]
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
@@ -356,7 +381,7 @@ impl<'a> Cur<'a> {
/// Binary DXF (R13+: 2-byte group codes).
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
parse_binary_with_limit(b, MAX_TAGS)
parse_binary_with_limit(b, max_tags())
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
@@ -421,17 +446,48 @@ pub fn write_ascii(tags: &[Tag]) -> String {
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
/// breaks inside strings become spaces either way.
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
let mut out = String::with_capacity(tags.len() * 16);
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
// Both forms are UTF-8 (the escaped one is ASCII).
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
/// DXF that is valid UTF-8 as UTF-8.
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
return ascii(tags, Strings::Escaped(None));
}
bytes
}
/// How a writer stores string values.
#[derive(Clone, Copy)]
enum Strings {
/// As they are (R2007+).
Utf8,
/// ASCII as it is, other characters in this code page when it has them, otherwise as
/// `\U+XXXX` escapes (before R2007).
Escaped(Option<codepage::Decoder>),
}
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(tags.len() * 16);
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) => escape_unicode(s, utf8, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
Value::Hex(h) => out.push_str(h),
Value::Str(s) => push_string(s, strings, &mut out),
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
Value::Hex(h) => out.extend_from_slice(h.as_bytes()),
}
out.push_str("\r\n");
out.extend_from_slice(b"\r\n");
}
out
}
@@ -444,6 +500,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 {
@@ -456,9 +522,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
Value::Str(s) => s.clone(),
v => v.as_str(),
};
let mut text = String::with_capacity(s.len());
escape_unicode(&s, utf8, &mut text);
out.extend(text.bytes().filter(|&b| b != 0));
let mut text = Vec::with_capacity(s.len());
push_string(&s, strings, &mut text);
out.extend(text.into_iter().filter(|&b| b != 0));
out.push(0);
}
Kind::Real => {
@@ -512,19 +578,27 @@ pub fn is_binary(bytes: &[u8]) -> bool {
bytes.starts_with(BINARY_SENTINEL)
}
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
/// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
let chars: Vec<char> = s.chars().collect();
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
for (i, &c) in chars.iter().enumerate() {
match c {
'\r' | '\n' => out.push(' '),
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c => {
match (c, strings) {
('\r' | '\n', _) => out.push(b' '),
('\\', _) if escape_at(i + 1) => {
out.extend_from_slice(b"\\U+005C");
}
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
(c, Strings::Escaped(_)) => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
let _ = write!(out, "\\U+{u:04X}");
@@ -534,6 +608,20 @@ fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
}
}
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
let start = out.len();
if !codepage::encode_char(c, d, out) {
return false;
}
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
out.truncate(start);
return false;
}
true
}
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
#[derive(Clone, Debug, Default)]
pub struct Section {
@@ -713,3 +801,27 @@ mod tests {
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn tag_limit_defaults_and_env_override() {
assert_eq!(max_tags_from(None), MAX_TAGS);
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
const { assert!(MAX_TAGS >= 100_000_000) };
}
#[test]
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
assert_eq!((tags.len(), tags.capacity()), (3, 3));
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
}
}
+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, ""),
],
);
}
+164 -1
View File
@@ -30,19 +30,50 @@ 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))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
/// `None` when the file's folder exists, or when `path` itself exists (a dangling symlink's target
/// folder is not ours to make).
pub fn missing_folder(path: &Path) -> Option<PathBuf> {
if std::fs::symlink_metadata(path).is_ok() {
return None;
}
path.ancestors().skip(1).filter(|a| !a.as_os_str().is_empty()).take_while(|a| std::fs::symlink_metadata(a).is_err()).last().map(Path::to_path_buf)
}
/// [`write_atomic`] to a path the user named, first creating the folders missing above it: a
/// mistyped folder costs less than a drawing that could not be written. The caller reports
/// [`missing_folder`], asked before this call, so the new folder is never a surprise.
pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(folder) = missing_folder(path) {
let dir = path.parent().unwrap_or(&folder);
std::fs::create_dir_all(dir).map_err(|e| io::Error::new(e.kind(), format!("the folder {} could not be created: {e}", dir.display())))?;
}
write_atomic(path, 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 +86,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
@@ -101,6 +180,30 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul
mod tests {
use super::*;
#[test]
fn creates_missing_parent_directories() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
let dest = dir.join("new_folder").join("deeper").join("out.bin");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(missing_folder(&dest), Some(dir.join("new_folder")));
assert!(write_atomic(&dest, b"created").is_err(), "the plain writer never makes folders");
write_atomic_making_folders(&dest, b"created").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"created");
assert_eq!(missing_folder(&dest), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_folder_that_cannot_be_made_is_named_in_the_error() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomk-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let blocker = dir.join("a_file");
std::fs::write(&blocker, b"x").unwrap();
let e = write_atomic_making_folders(&blocker.join("sub").join("out.bin"), b"new").unwrap_err();
assert!(e.to_string().contains("could not be created"), "{e}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
@@ -116,6 +219,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 +262,54 @@ 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!(write_atomic_making_folders(&link, b"new").is_err());
assert!(!dir.join("missing").exists(), "a link's unavailable target folder must not be created");
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() {
+5 -4
View File
@@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
missing("table style", "Standard", out);
d.table_styles.insert(0, TableStyle::default());
}
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
for l in &mut d.layers {
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
out.push(issue(
"missingLinetype",
"tables",
@@ -247,8 +248,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
l.linetype = "Continuous".into();
}
}
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
for s in &mut d.dim_styles {
if !known(&s.text_style) {
out.push(issue(
+159 -21
View File
@@ -4,15 +4,18 @@
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
//! another copy running at the same time doesn't offer its files. The system drops the lock the
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//!
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
//! are profile settings: process-wide, not saved in the drawing.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
@@ -29,11 +32,27 @@ pub const DEFAULT_SAVETIME: u32 = 10;
pub const MAX_SAVETIME: u32 = 600;
/// A lock file not refreshed for this long belongs to an app that is no longer running.
pub const STALE_LOCK_SECS: u64 = 5 * 60;
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
/// stopped. Older versions write only the process id and never hold one.
#[cfg(not(target_arch = "wasm32"))]
const LOCK_HELD: &str = " held";
/// How often a running app refreshes its lock file.
#[cfg(not(target_arch = "wasm32"))]
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.
@@ -137,13 +156,21 @@ pub struct Autosaver {
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// Writes in progress; each tells whether its file was written.
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
failed: BTreeMap<u64, String>,
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
#[cfg(not(target_arch = "wasm32"))]
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
/// locks don't work and only the file's age counts.
#[cfg(not(target_arch = "wasm32"))]
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
}
impl Autosaver {
@@ -152,12 +179,15 @@ impl Autosaver {
instance: instance.into(),
docs: HashMap::new(),
busy: HashSet::new(),
failed: BTreeMap::new(),
#[cfg(not(target_arch = "wasm32"))]
running: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant::now(),
#[cfg(not(target_arch = "wasm32"))]
last_beat: None,
#[cfg(not(target_arch = "wasm32"))]
locks: Vec::new(),
}
}
@@ -175,6 +205,11 @@ impl Autosaver {
self.docs.values().filter_map(|t| t.file.clone()).collect()
}
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
self.failed.iter().map(|(uid, why)| (*uid, why.as_str()))
}
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
/// files of drawings that were saved (no unsaved changes left) or closed.
@@ -208,6 +243,7 @@ impl Autosaver {
jobs.push(Job::Delete { path });
}
*t = Tracked::default();
self.failed.remove(&st.uid);
continue;
}
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
@@ -236,6 +272,8 @@ impl Autosaver {
t.since = None;
jobs.push(Job::Write { uid: st.uid, path, doc });
}
let docs = &self.docs;
self.failed.retain(|uid, _| docs.contains_key(uid));
jobs
}
@@ -265,12 +303,14 @@ pub use native::*;
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::fs::{File, OpenOptions, TryLockError};
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, 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.
@@ -295,6 +335,28 @@ mod native {
Some(SystemTime::now().duration_since(m).unwrap_or_default())
}
/// Whether the app instance named by a lock file in `dir` may still be running: its system
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
/// file from an older version).
fn owner_alive(dir: &Path, instance: &str) -> bool {
let by_age = || instance_alive(lock_age(dir, instance));
let path = lock_path(dir, instance);
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
return by_age();
}
let Ok(file) = File::open(&path) else { return by_age() };
match file.try_lock() {
Err(TryLockError::WouldBlock) => true,
Err(TryLockError::Error(_)) => by_age(),
Ok(()) => {
let mut text = String::new();
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
!held && by_age()
}
}
}
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
/// `own`, this app's instance), oldest first.
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
@@ -304,7 +366,7 @@ mod native {
for e in entries.flatten().take(10_000) {
let name = e.file_name().to_string_lossy().to_string();
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
continue;
}
let Ok(meta) = e.metadata() else { continue };
@@ -346,7 +408,7 @@ mod native {
for n in &names {
let Some(instance) = parse_lock_name(n) else { continue };
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !instance_alive(lock_age(dir, instance)) {
if !has_files && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -404,24 +466,67 @@ 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())
}
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
/// another [`Autosaver::tick`] to learn whether it failed.
pub fn results_pending(&self) -> bool {
!self.running.is_empty()
}
/// 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.
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
/// says why until a write succeeds.
pub fn reap(&mut self) {
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
self.running = running;
for (uid, h) in done {
self.busy.remove(&uid);
if !h.join().unwrap_or(false) {
self.unwritten(uid);
}
self.settle(uid, h);
}
}
fn unwritten(&mut self, uid: u64) {
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
self.busy.remove(&uid);
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
Ok(()) => {
self.failed.remove(&uid);
}
Err(e) => self.unwritten(uid, e),
}
}
/// Write every drawing with changes not yet autosaved and wait until it is done, because
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
/// how many drawings were written.
pub fn emergency_save(&mut self, s: &Session) -> usize {
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
let now = self.start.elapsed().as_secs_f64();
let dir = savefilepath();
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
self.perform(jobs);
self.last_beat = None;
self.heartbeat(&dir, now);
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
}
fn unwritten(&mut self, uid: u64, why: String) {
if let Some(t) = self.docs.get_mut(&uid) {
t.written = None;
}
self.failed.insert(uid, why);
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
@@ -432,16 +537,17 @@ mod native {
Job::Write { uid, path, doc } => {
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
log::warn!("autosave: file formats are not available in this build");
self.unwritten(uid, "file formats are not available in this build".to_string());
continue;
};
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
Ok(()) => {
log::info!("autosaved {path}");
true
Ok(())
}
Err(e) => {
log::warn!("autosave {path}: {e}");
false
Err(format!("{path}: {e}"))
}
});
match spawned {
@@ -451,7 +557,7 @@ mod native {
}
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid);
self.unwritten(uid, e.to_string());
}
}
}
@@ -466,12 +572,40 @@ mod native {
}
self.last_beat = Some(now);
let dir = Path::new(dir);
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
if let Err(e) = r {
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
log::warn!("autosave: lock file in {}: {e}", dir.display());
}
}
/// Refresh this app's lock files, and start one at `path` if there isn't one yet, taking
/// the system lock on it if it can. Without that lock the file still works by its age.
/// Only trouble at `path` is an error: an older folder may be gone by now.
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
let id = std::process::id();
let mut current = None;
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => std::fs::write(held, id.to_string()),
};
if held == path {
current = Some(refreshed);
}
}
if let Some(refreshed) = current {
return refreshed;
}
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return std::fs::write(path, id.to_string());
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
self.locks.push((path.to_path_buf(), Some(file)));
Ok(())
}
/// The app is closing normally: finish writes in progress, then delete this app's autosave
/// files and its lock file.
pub fn shutdown(&mut self) {
@@ -483,6 +617,10 @@ mod native {
discard(&path);
}
self.docs.clear();
for (path, file) in self.locks.drain(..) {
drop(file);
let _ = std::fs::remove_file(path);
}
let dir = savefilepath();
if !dir.is_empty() && is_instance_id(&self.instance) {
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
+3 -1
View File
@@ -268,11 +268,12 @@ impl DocState {
}
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
/// undo history, nothing selected, model space.
/// undo history, nothing selected, nothing for OOPS, model space.
fn enter_block_editor(&mut self, be: BlockEdit) {
self.undo.clear();
self.redo.clear();
self.undo_ctl = Default::default();
self.erased = Default::default();
self.selection.clear();
self.previous_selection.clear();
self.space = cadcraft_doc::Space::Model;
@@ -338,6 +339,7 @@ impl DocState {
self.undo = host.undo;
self.redo = host.redo;
self.undo_ctl = host.undo_ctl;
self.erased = host.erased;
// The drawing comes back with nothing selected; Previous is what was selected before.
self.selection.clear();
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
+2
View File
@@ -22,9 +22,11 @@ mod inquiry;
mod layer;
mod layout;
mod lisp;
mod merge;
mod mleader_content;
mod mleader_edit;
mod modify;
pub(crate) use modify::Erased;
mod modify2;
mod mtext_opts;
mod overkill;
+5 -1
View File
@@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions
mod angular;
mod common;
mod edit;
mod leader;
mod linear;
mod mleader;
mod radial;
@@ -88,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["mld"])
.params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
CommandSpec::new("leader", "Leader", run_leader)
.alias(&["lead"])
.params("{points: [[x,y]...], text?}")
.interactive(|_| Ok(Box::new(leader::LeaderM::default()))),
CommandSpec::new("dimstyle.update", "Update", run_update)
.menu(&["Dimension", "Update"])
.params("{handles?}")
+1
View File
@@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
})
}
+330
View File
@@ -0,0 +1,330 @@
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the
//! points, Format and Undo, then annotation text, no annotation, or a block.
//!
//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is
//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the
//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to.
//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the
//! drawing model has no link from a leader to its annotation.
use super::common::style;
use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3};
use crate::cmd::curves::KW;
/// Where LEADER is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Points,
Format,
FirstLine,
NextLine,
Option,
/// Copy: the text or multiline text to copy.
Copy,
BlockName,
BlockPoint,
BlockX,
BlockY,
BlockRotation,
}
#[derive(Default)]
pub(super) struct LeaderM {
ask: Ask,
pts: Vec<Vec2>,
spline: bool,
no_arrow: bool,
lines: Vec<String>,
block: String,
block_at: Vec2,
block_scale: Vec2,
}
/// What follows the leader.
enum Annotation {
Text(Vec<String>),
/// A copy of a text or multiline text: its contents, height, width and style.
Copy(MText),
None,
Block {
name: String,
at: Vec2,
scale: Vec2,
rotation: f64,
},
}
impl Interactive for LeaderM {
fn name(&self) -> &'static str {
"LEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
let last = self.pts.last().copied();
match self.ask {
Ask::Points => match self.pts.len() {
0 => Prompt::new("Specify leader start point", Accept::POINT),
1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last),
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last),
},
Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"),
// "or <options>": the default is the options, not a keyword list.
Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"),
Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT),
Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT),
Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"),
Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"),
Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Points => match i {
Input::Point(p) => self.pts.push(p),
// Enter after one point: no leader.
Input::Enter if self.pts.len() < 2 => return Ok(Step::Done),
Input::Enter => self.ask = Ask::FirstLine,
Input::Keyword(k) => match k.as_str() {
"Annotation" => self.ask = Ask::FirstLine,
"Format" => self.ask = Ask::Format,
_ => {
self.pts.pop();
}
},
_ => {}
},
Ask::Format => {
// Enter, or Exit typed, changes nothing; either way back to the points.
if let Input::Keyword(k) = &i {
match k.as_str() {
"Spline" => self.spline = true,
"STraight" => self.spline = false,
"Arrow" => self.no_arrow = false,
_ => self.no_arrow = true,
}
}
self.ask = Ask::Points;
}
Ask::FirstLine | Ask::NextLine => match i {
Input::Text(t) if !t.is_empty() => {
self.lines.push(t);
self.ask = Ask::NextLine;
}
_ if self.ask == Ask::NextLine => {
let lines = std::mem::take(&mut self.lines);
return self.finish(s, Annotation::Text(lines));
}
_ => self.ask = Ask::Option,
},
Ask::Option => match &i {
Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None),
Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName,
Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy,
Input::Text(_) => s.echo("Invalid option keyword."),
// Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the
// command line, as in accoreconsole, they come back to the first line.
_ => self.ask = Ask::FirstLine,
},
Ask::Copy => {
let picked = match &i {
Input::Pick(hs) => hs.first().copied(),
Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()),
_ => None,
};
let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind {
EntityKind::Text(t) => Some(MText {
insert: t.insert,
height: t.height,
width: 0.0,
attach: 4,
rotation: 0.0,
style: t.style.clone(),
contents: t.value.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
EntityKind::MText(m) => Some(m.clone()),
_ => None,
});
match text {
Some(m) => return self.finish(s, Annotation::Copy(m)),
None if picked.is_some() => s.echo("The object is not text."),
None => {}
}
}
Ask::BlockName => {
let name = match i {
Input::Text(t) => t.trim().to_string(),
Input::Keyword(k) => k,
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
} else if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
} else {
self.block = name;
self.ask = Ask::BlockPoint;
}
}
Ask::BlockPoint => match i {
Input::Point(p) => {
self.block_at = p;
self.ask = Ask::BlockX;
}
Input::Keyword(_) => s.echo("This option is not available here yet."),
_ => {}
},
Ask::BlockX => match scale(&i) {
Some(x) => {
self.block_scale = Vec2::new(x, x);
self.ask = Ask::BlockY;
}
None => s.echo("Requires a nonzero number."),
},
Ask::BlockY => {
match scale(&i) {
Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y,
Some(_) => {}
None => {
s.echo("Requires a nonzero number.");
return Ok(Step::Continue);
}
}
self.ask = Ask::BlockRotation;
}
Ask::BlockRotation => {
let rotation = match &i {
Input::Enter => 0.0,
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires a valid angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation };
return self.finish(s, a);
}
}
Ok(Step::Continue)
}
}
/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number.
fn scale(i: &Input) -> Option<f64> {
match i {
Input::Enter => Some(1.0),
Input::Text(t) => t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v != 0.0),
_ => None,
}
}
impl LeaderM {
fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result<Step> {
let pts = std::mem::take(&mut self.pts);
add_leader(s, &pts, self.spline, !self.no_arrow, ann)?;
Ok(Step::Done)
}
}
/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE.
struct Sizes {
arrow: f64,
gap: f64,
text: f64,
above: bool,
text_style: String,
}
fn sizes(s: &Session) -> Sizes {
// The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets).
let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default();
let k = if st.scale > 0.0 { st.scale } else { 1.0 };
let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() };
Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style }
}
/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at
/// `\n`) placed as the command line places it. The handles of the leader and its text.
pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result<Vec<Handle>> {
let ann = match text {
Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()),
_ => Annotation::None,
};
add_leader(s, pts, false, true, ann)
}
/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles.
fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result<Vec<Handle>> {
let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) };
let z = sizes(s);
// The side the leader runs to: right unless its last segment runs left.
let dir = if end.x < before.x { -1.0 } else { 1.0 };
let mut vertices = pts.to_vec();
if let Annotation::Text(_) | Annotation::Copy(_) = ann {
let d = end - before;
// A copied text is centred on its line, and with DIMTAD on that line is above the leader:
// the leader ends half the text height lower.
if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) {
last.y -= z.text / 2.0;
}
let end = vertices.last().copied().unwrap_or(end);
// More than 15° off horizontal: a hook line DIMASZ long into the end.
if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() {
let at = vertices.len() - 1;
vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y));
}
}
let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: vertices.iter().map(|p| v3(*p)).collect(),
arrow,
spline,
style: style(s),
}))?;
let annotation = match ann {
Annotation::Text(lines) => {
let (attach, lift) = match (z.above, dir > 0.0) {
(true, true) => (7, z.gap),
(true, false) => (9, z.gap),
(false, true) => (4, 0.0),
(false, false) => (6, 0.0),
};
Some(s.add_entity(EntityKind::MText(MText {
insert: v3(end + Vec2::new(dir * z.gap, lift)),
height: z.text,
width: 0.0,
attach,
rotation: 0.0,
style: z.text_style,
contents: lines.join("\\P"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?)
}
Annotation::Copy(m) => {
let lift = if z.above { z.gap } else { 0.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?)
}
Annotation::Block { name, at, scale, rotation } => {
match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) {
Ok(h) => Some(h),
Err(e) => {
s.doc_mut()?.remove_entity(leader);
return Err(e);
}
}
}
Annotation::None => None,
};
Ok(std::iter::once(leader).chain(annotation).collect())
}
+2 -28
View File
@@ -1,4 +1,3 @@
use super::common::style;
use super::{
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
bad, json, points_param, str_param, v3,
@@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Err(bad("leader", "need 2+ points"));
}
let st = style(s);
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: pts.iter().map(|q| v3(*q)).collect(),
arrow: true,
spline: false,
style: st,
}))?;
let mut out = vec![h.hex()];
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
out.push(
s.add_entity(EntityKind::MText(MText {
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
height: th,
width: 0.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
}
Ok(json!({ "handles": out }))
let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?;
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// The MLEADER Options prompt being answered.
+11 -2
View File
@@ -1,4 +1,5 @@
//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`).
//! Array commands: ARRAY, ARRAYRECT, ARRAYPOLAR and the classic -ARRAY (ARRAYPATH lives in
//! `modify2`).
//!
//! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an
//! anonymous source block and the array becomes one block reference to an anonymous array block
@@ -21,6 +22,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod classic;
mod edit;
/// Most preview entities drawn while the array is edited.
@@ -48,6 +50,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}",
)
.interactive(|_| Ok(Box::new(PolarM::default()))),
CommandSpec::new("-array", "Array", classic::run_classic)
.params("{handles?, type?: \"rectangular\" (default) | \"polar\", rows? (1), cols? (1), rowSpacing?, colSpacing?, center, count, angle? (degrees, default 360), rotate?: bool (default true), base? ([x, y], default the DXF group 10 point of the last object)} → {created}: separate copies, as the classic -ARRAY makes them")
.interactive(|_| Ok(Box::new(classic::ClassicM::default()))),
]
.into_iter()
.chain(edit::specs())
@@ -369,10 +374,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
let row_spacing = f64_or(p, "rowSpacing", 1.0);
let fill = f64_or(p, "angle", 360.0);
if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) {
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
}
let kind = ArrayKind::Polar(PolarArray {
center: c - base,
items: count32(n),
fill: f64_or(p, "angle", 360.0),
fill: fill.clamp(-360.0, 360.0),
rotate: bool_or(p, "rotate", true),
rows: count32(rows),
row_spacing,
+255
View File
@@ -0,0 +1,255 @@
//! -ARRAY: the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
//! (accoreconsole, every copy read back with `entget`). It makes separate copies, never an
//! associative array:
//! - "Enter the type of array [Rectangular/Polar] <R>": the default is the type used last;
//! - Rectangular asks "Enter the number of rows (---) <1>" and "Enter the number of columns (|||)
//! <1>", then "Enter the distance between rows or specify unit cell (---)" only for more than one
//! row (two corners give both distances) and "Specify the distance between columns (|||)" only for
//! more than one column. The copy in row r and column c moves by (c·columns, r·rows): 2 rows and
//! 2 columns at −12.57 and −18.01 put the copies below and to the left;
//! - Polar asks "Specify center point of array or [Base]", "Enter the number of items in the
//! array", "Specify the angle to fill (+=ccw, -=cw) <360>" and "Rotate arrayed objects?
//! [Yes/No] <Y>". Items are 360/n apart around a full circle, else fill/(n − 1) apart (6 items in
//! 270°: 54°; −90° turns clockwise). Not rotated, each copy moves as the DXF group 10 point of the
//! object selected last turns about the centre (a polyline's first vertex, an arc's centre), or
//! as the Base point does.
use cadcraft_doc::Handle;
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::{bad, bool_or, f64_or, point_param, point_req, str_param, targets};
use super::{KEYWORDS, angle, check_size, copy_all, count, count_or, max_count, other, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Select,
Kind,
Rows,
Columns,
RowDistance,
/// The unit cell's other corner, after the first one.
Corner(Vec2),
ColumnDistance,
Center,
Base,
Items,
Fill,
Rotate,
}
#[derive(Default)]
pub(super) struct ClassicM {
sel: SelectPhase,
objs: Vec<Handle>,
stage: Stage,
rows: u64,
columns: u64,
row_distance: f64,
center: Vec2,
base: Option<Vec2>,
items: u64,
fill: f64,
}
/// The moves of a rectangular array's copies (the objects themselves stay in row 0, column 0).
fn rect_mats(rows: u64, columns: u64, row_distance: f64, column_distance: f64) -> Vec<Mat3> {
(0..rows)
.flat_map(|r| (0..columns).map(move |c| (r, c)))
.filter(|rc| *rc != (0, 0))
.map(|(r, c)| Mat3::translate(Vec2::new(c as f64 * column_distance, r as f64 * row_distance)))
.collect()
}
/// The transforms of a polar array's copies: `items` around `center` filling `fill` degrees,
/// turned with it or moved as `base` turns.
fn polar_mats(center: Vec2, items: u64, fill: f64, rotate: bool, base: Vec2) -> Vec<Mat3> {
let full = (fill.abs() - 360.0).abs() < 1e-9;
let gaps = if full { items } else { items.saturating_sub(1) }.max(1);
let step = fill.to_radians() / gaps as f64;
(1..items)
.map(|k| {
let a = step * k as f64;
if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(base.rotate_about(center, a) - base) }
})
.collect()
}
/// The DXF group 10 point of the last of `hs`, which a polar array that doesn't rotate its copies
/// moves around the centre.
fn last_point(s: &Session, hs: &[Handle]) -> Option<Vec2> {
let d = s.doc().ok()?;
hs.last().and_then(|h| d.entity(*h)).and_then(|e| crate::session::group_10(d, e))
}
/// -ARRAY's JSON form.
pub(super) fn run_classic(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let polar = match str_param(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("rectangular") => false,
Some("polar") => true,
Some(_) => return Err(bad("-array", "type must be \"rectangular\" or \"polar\"")),
};
// The size is checked before the copies' transforms are made.
let mats = if polar {
let center = point_req("-array", p, "center")?;
let items = count_or("-array", p, "count", 1)?;
check_size(s, items, hs.len())?;
let base = point_param(p, "base").or_else(|| last_point(s, &hs)).unwrap_or(center);
polar_mats(center, items, f64_or(p, "angle", 360.0), bool_or(p, "rotate", true), base)
} else {
let rows = count_or("-array", p, "rows", 1)?;
let columns = count_or("-array", p, "cols", 1)?;
check_size(s, rows.saturating_mul(columns), hs.len())?;
rect_mats(rows, columns, f64_or(p, "rowSpacing", 0.0), f64_or(p, "colSpacing", 0.0))
};
let created = copy_all(s, &hs, mats.into_iter())?;
Ok(json!({ "created": created }))
}
impl ClassicM {
/// Make the copies.
fn finish(&mut self, s: &mut Session, mats: Vec<Mat3>) -> Result<Step> {
check_size(s, mats.len() as u64 + 1, self.objs.len())?;
copy_all(s, &self.objs, mats.into_iter())?;
s.set_selection(Vec::new());
Ok(Step::Done)
}
/// After the rows and columns: the distances still needed, or the copies.
fn after_counts(&mut self, s: &mut Session) -> Result<Step> {
if self.rows > 1 {
self.stage = Stage::RowDistance;
} else if self.columns > 1 {
self.stage = Stage::ColumnDistance;
} else {
return self.finish(s, Vec::new());
}
Ok(Step::Continue)
}
fn rect(&mut self, s: &mut Session, column_distance: f64) -> Result<Step> {
let mats = rect_mats(self.rows, self.columns, self.row_distance, column_distance);
self.finish(s, mats)
}
}
impl Interactive for ClassicM {
fn name(&self) -> &'static str {
"-ARRAY"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
self.stage = Stage::Kind;
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.stage {
Stage::Select => self.sel.prompt(),
Stage::Kind => Prompt::new("Enter the type of array", KEYWORDS)
.kw(&["Rectangular", "Polar"])
.default(if s.last_used.classic_array_polar { "P" } else { "R" }),
Stage::Rows => Prompt::new("Enter the number of rows (---)", Accept::NUMBER).default("1"),
Stage::Columns => Prompt::new("Enter the number of columns (|||)", Accept::NUMBER).default("1"),
Stage::RowDistance => Prompt::new("Enter the distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
Stage::Corner(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
Stage::ColumnDistance => Prompt::new("Specify the distance between columns (|||)", Accept::NUMBER),
Stage::Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base"]),
Stage::Base => Prompt::new("Specify base point of objects", Accept::POINT),
Stage::Items => Prompt::new("Enter the number of items in the array", Accept::NUMBER),
Stage::Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default("360"),
Stage::Rotate => Prompt::new("Rotate arrayed objects?", KEYWORDS).kw(&["Yes", "No"]).default("Y"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let max = max_count(s, 1, self.objs.len());
match (self.stage, i) {
(Stage::Select, i) => match self.sel.feed(s, &i)? {
SelOutcome::More => {}
SelOutcome::Done(hs) => {
self.objs = hs;
self.stage = Stage::Kind;
}
SelOutcome::Empty => return Ok(Step::Done),
},
(Stage::Kind, i) => {
let polar = match &i {
Input::Enter => s.last_used.classic_array_polar,
Input::Keyword(k) | Input::Text(k) => match k.trim().trim_start_matches('_').to_ascii_lowercase().chars().next() {
Some('p') => true,
Some('r') => false,
_ => return Err(other("Invalid option keyword.")),
},
_ => return Ok(Step::Continue),
};
s.last_used.classic_array_polar = polar;
self.stage = if polar { Stage::Center } else { Stage::Rows };
}
(Stage::Rows, i) => {
if let Some(n) = count(&i, 1, max)? {
self.rows = n;
self.stage = Stage::Columns;
}
}
// No more columns than MAXARRAY allows with the rows.
(Stage::Columns, i) => {
if let Some(n) = count(&i, 1, max_count(s, self.rows, self.objs.len()))? {
self.columns = n;
return self.after_counts(s);
}
}
(Stage::RowDistance, Input::Point(a)) => self.stage = Stage::Corner(a),
(Stage::RowDistance, Input::Text(t)) => {
self.row_distance = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
if self.columns <= 1 {
return self.rect(s, 0.0);
}
self.stage = Stage::ColumnDistance;
}
// A unit cell gives both distances.
(Stage::Corner(a), Input::Point(b)) => {
self.row_distance = b.y - a.y;
return self.rect(s, b.x - a.x);
}
(Stage::ColumnDistance, Input::Text(t)) => {
let d = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
return self.rect(s, d);
}
(Stage::Center, Input::Point(c)) => {
self.center = c;
self.stage = Stage::Items;
}
(Stage::Center, Input::Keyword(k)) if k == "Base" => self.stage = Stage::Base,
(Stage::Base, Input::Point(b)) => {
self.base = Some(b);
self.stage = Stage::Center;
}
(Stage::Items, i @ Input::Text(_)) => {
if let Some(n) = count(&i, 1, max)? {
self.items = n;
self.stage = Stage::Fill;
}
}
(Stage::Fill, i) => {
if let Some(a) = angle(&i, 360.0, &s.angle_settings())? {
self.fill = a;
self.stage = Stage::Rotate;
}
}
(Stage::Rotate, i) => {
let Some(rotate) = yes_no(&i, true) else { return Err(other("Invalid option keyword.")) };
let base = self.base.or_else(|| last_point(s, &self.objs)).unwrap_or(self.center);
let mats = polar_mats(self.center, self.items, self.fill, rotate, base);
return self.finish(s, mats);
}
_ => {}
}
Ok(Step::Continue)
}
}
+238 -121
View File
@@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge;
use std::sync::Arc;
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::helpers::v3;
@@ -17,12 +17,14 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po
use super::has_doc;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attdef;
mod attedit;
mod burst;
mod description;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
mod wblock;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -41,14 +43,18 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["i", "-insert", "ddinsert"])
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
.interactive(|_| Ok(Box::new(InsertM::default()))),
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
.menu(&["Draw", "Block", "Define Attributes..."])
.alias(&["att", "-attdef"])
.params("{tag, prompt?, default?, at, height?, invisible?}")
.interactive(|_| Ok(Box::new(AttdefM::default()))),
.interactive(|_| Ok(Box::new(attdef::AttdefM::default()))),
attedit::attedit_spec(),
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
wblock::wblock_spec(),
wblock::dash_wblock_spec(),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
@@ -184,9 +190,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
b.entities.push(e);
}
doc.blocks.insert(name.to_string(), Arc::new(b));
if keep != Keep::Retain {
for h in hs {
doc.remove_entity(*h);
match keep {
Keep::Retain => {}
Keep::Convert => {
for h in hs {
doc.remove_entity(*h);
}
}
// As after ERASE, OOPS brings them back.
Keep::Delete => {
super::modify::erase_kept(s, hs)?;
}
}
s.set_selection(Vec::new());
@@ -223,6 +236,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
ins
}
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
/// reference.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Grid {
rows: u32,
cols: u32,
row_spacing: f64,
col_spacing: f64,
}
impl Grid {
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
const MAX: u32 = 32767;
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
}
fn apply(self, ins: &mut Insert) {
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
}
}
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
/// [`insert_entity`]).
pub(crate) fn insert_with(
@@ -234,10 +274,12 @@ pub(crate) fn insert_with(
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
) -> Result<Handle> {
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
}
/// [`insert_with`] with a Z scale factor of its own.
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
/// of attributes (AutoCAD 2024 asks for their values once).
#[allow(clippy::too_many_arguments)]
fn insert_scaled(
s: &mut Session,
name: &str,
@@ -246,9 +288,11 @@ fn insert_scaled(
rotation: f64,
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
grid: Grid,
) -> Result<Handle> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let mut ins = insert_entity(&blk, at, scale, rotation, base);
grid.apply(&mut ins);
let m = ins.transform(blk.base.xy());
for e in blk.entities.iter() {
if let EntityKind::AttDef(ad) = &e.kind {
@@ -300,24 +344,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
insert_json(s, p, "insert", Grid::ONE)
}
/// MINSERT's JSON form: INSERT's parameters and the grid.
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
let count = |key: &str| -> Result<u32> {
match p.get(key) {
None | Some(Value::Null) => Ok(1),
Some(v) => v
.as_f64()
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
.map(|n| n as u32)
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
}
};
// A distance given must be a number: `"15"` is refused, not taken as 0.
let spacing = |key: &str| -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
}
};
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
insert_json(s, p, "minsert", grid)
}
/// INSERT's and MINSERT's JSON form.
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
let p = &insert_file::json_params(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
let at = point_req("insert", p, "at")?;
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
let at = point_req(cmd, p, "at")?;
// `scale`: one factor, or [x, y] for different X and Y scale factors.
let scale = match p.get("scale") {
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
_ => {
let f = f64_or(p, "scale", 1.0);
Vec2::new(f, f)
}
};
if scale.x == 0.0 || scale.y == 0.0 {
return Err(bad("insert", "scale cannot be 0"));
return Err(bad(cmd, "scale cannot be 0"));
}
let rot = f64_or(p, "rotation", 0.0).to_radians();
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
if bool_or(p, "explode", false) {
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
if cmd == "insert" && bool_or(p, "explode", false) {
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
@@ -361,53 +433,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let src = s.doc()?;
let mut d = cadcraft_doc::Drawing::new_imperial();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.blocks = src.blocks.clone();
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
// objects and the block's base point for a block (`base` is then in block coordinates).
let base = point_param(p, "base");
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
b.entities
.iter()
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
} else {
targets(s, p)?
.iter()
.filter_map(|h| src.entity(*h))
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
};
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
}
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("base", p, "at")?;
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
@@ -416,8 +441,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
// One object index for the whole listing: rebuilding it per block multiplies the block count
// by the drawing's kept object count (CWE-407).
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
@@ -594,7 +622,18 @@ enum InsStage {
/// XYZ: the Z scale factor, after X and Y.
ZScale,
Rotation,
/// MINSERT: the number of rows, then of columns.
Rows,
Cols,
/// MINSERT: the distance between rows, or a unit cell's first corner.
RowSpacing,
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
/// rows).
Cell(Vec2),
ColSpacing,
Attribs,
/// A Verify attribute asked again.
Verify,
}
#[derive(Default)]
@@ -615,12 +654,21 @@ struct InsertM {
/// Where the block was dropped to pick a Basepoint on it.
dropped: Option<Vec2>,
rotation: Option<f64>,
attdefs: Vec<(String, String, String)>,
/// The attributes asked for: tag, prompt, default value, and Verify (asked again).
attdefs: Vec<(String, String, String, bool)>,
values: serde_json::Map<String, Value>,
/// Verify attributes asked again so far.
verified: usize,
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
explode: bool,
/// The file waiting for the Redefine answer.
file: Option<insert_file::FileRef>,
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
minsert: bool,
rows: Option<u32>,
cols: Option<u32>,
row_spacing: Option<f64>,
col_spacing: Option<f64>,
}
impl InsertM {
@@ -630,14 +678,27 @@ impl InsertM {
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
} else if self.rotation.is_none() {
InsStage::Rotation
} else if let Some(g) = self.grid_stage() {
g
} else {
InsStage::Attribs
};
if self.stage == InsStage::Attribs && self.values.is_empty() && !self.attdefs.is_empty() {
s.echo("Enter attribute values");
}
// Then the Verify attributes are asked again, with the values typed.
if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && self.verified < self.verifying().len() {
if self.verified == 0 {
s.echo("Verify attribute values");
}
self.stage = InsStage::Verify;
return Ok(Step::Continue);
}
if self.stage == InsStage::Attribs
&& self.values.len() >= self.attdefs.len()
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
{
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
if self.explode {
insert_file::explode_insert(s, h)?;
}
@@ -646,20 +707,50 @@ impl InsertM {
Ok(Step::Continue)
}
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
/// columns, then the distance between rows when there is more than one row and between
/// columns when there is more than one column. A unit cell gives both distances.
fn grid_stage(&self) -> Option<InsStage> {
if !self.minsert {
return None;
}
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
};
if rows > 1 && self.row_spacing.is_none() {
Some(InsStage::RowSpacing)
} else if cols > 1 && self.col_spacing.is_none() {
Some(InsStage::ColSpacing)
} else {
None
}
}
/// The grid answered so far (one copy for INSERT).
fn grid(&self) -> Grid {
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
}
/// The Verify attributes asked for.
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
self.attdefs.iter().filter(|a| a.3).collect()
}
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point. Constant
/// and Preset attributes take their values without asking, and so do all of them with ATTREQ 0.
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
let Some(b) = s.doc()?.block(name).cloned() else {
s.echo(format!("Block \"{name}\" not found."));
return Ok(());
};
self.attdefs = if self.explode {
self.attdefs = if self.explode || !s.settings.attreq {
Vec::new()
} else {
b.entities
.iter()
.filter_map(|e| {
if let EntityKind::AttDef(a) = &e.kind {
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
(!a.constant && !a.modes.preset).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone(), a.modes.verify))
} else {
None
}
@@ -710,13 +801,18 @@ impl InsertM {
}
}
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
fn distance(t: &str) -> Result<f64> {
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
}
fn nonzero(t: &str) -> Result<f64> {
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
}
impl Interactive for InsertM {
fn name(&self) -> &'static str {
"INSERT"
if self.minsert { "MINSERT" } else { "INSERT" }
}
fn prompt(&self, s: &Session) -> Prompt {
let at = self.at.unwrap_or_default();
@@ -743,9 +839,21 @@ impl Interactive for InsertM {
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
InsStage::Attribs => {
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
if default.is_empty() { p } else { p.default(default) }
}
InsStage::Verify => {
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
let typed = self.values.get(&tag).and_then(Value::as_str).unwrap_or_default().to_string();
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(typed)
}
}
}
@@ -762,9 +870,9 @@ impl Interactive for InsertM {
s.echo(format!("Defined blocks: {}", names.join(", ")));
return Ok(Step::Continue);
}
// `*name`: insert exploded.
self.explode = n.starts_with('*');
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
self.explode = !self.minsert && n.starts_with('*');
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
match insert_file::file_ref(s, &n) {
Some(f) if f.ask_redefine(s) => {
self.file = Some(f);
@@ -904,8 +1012,47 @@ impl Interactive for InsertM {
});
return self.advance(s);
}
InsStage::Rows | InsStage::Cols => {
let n = match i {
Input::Text(t) => t
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
.map(|v| v as u32)
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
Input::Enter => 1,
_ => return Ok(Step::Continue),
};
if self.stage == InsStage::Rows {
self.rows = Some(n);
} else {
self.cols = Some(n);
}
return self.advance(s);
}
InsStage::RowSpacing => match i {
Input::Point(a) => self.stage = InsStage::Cell(a),
Input::Text(t) => {
self.row_spacing = Some(distance(&t)?);
return self.advance(s);
}
_ => {}
},
InsStage::Cell(a) => {
if let Input::Point(b) = i {
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
return self.advance(s);
}
}
InsStage::ColSpacing => {
if let Input::Text(t) = i {
self.col_spacing = Some(distance(&t)?);
return self.advance(s);
}
}
InsStage::Attribs => {
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let v = match i {
Input::Text(t) => t,
_ => default,
@@ -913,6 +1060,20 @@ impl Interactive for InsertM {
self.values.insert(tag, Value::String(v));
return self.advance(s);
}
// Enter keeps the value typed.
InsStage::Verify => {
let tag = self.verifying().get(self.verified).map(|a| a.0.clone()).unwrap_or_default();
match i {
Input::Text(t) => {
self.values.insert(tag, Value::String(t));
}
Input::Enter => {}
// A click or anything else is no answer: the value is asked for again.
_ => return Ok(Step::Continue),
}
self.verified += 1;
return self.advance(s);
}
}
Ok(Step::Continue)
}
@@ -930,53 +1091,9 @@ impl Interactive for InsertM {
_ => Vec2::new(1.0, 1.0),
});
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
}
}
#[derive(Default)]
struct AttdefM {
tag: Option<String>,
prompt: Option<String>,
default: Option<String>,
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
_ => Prompt::new("Specify start point of text", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match &i {
Input::Text(t) => Some(t.clone()),
Input::Enter => Some(String::new()),
_ => None,
};
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => {
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
self.tag = Some(t.to_ascii_uppercase());
}
}
(Some(_), None, _) => self.prompt = text,
(Some(_), Some(_), None) => self.default = text,
(Some(tag), Some(pr), Some(def)) => {
if let Input::Point(p) = i {
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
let k = attdef_kind(s, tag, pr, def, p, h, false);
s.add_entity(k)?;
return Ok(Step::Done);
}
}
}
Ok(Step::Continue)
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
self.grid().apply(&mut ins);
vec![EntityKind::Insert(ins)]
}
}
+293
View File
@@ -0,0 +1,293 @@
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes
//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead),
//! then the text's point, justification, style, height and rotation.
use cadcraft_doc::{AttribModes, HAlign, VAlign};
use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3};
use crate::cmd::curves::KW;
use crate::cmd::draw::justify;
/// AFLAGS bits, as AutoCAD numbers the modes.
const INVISIBLE: i64 = 1;
const CONSTANT: i64 = 2;
const VERIFY: i64 = 4;
const PRESET: i64 = 8;
const LOCK: i64 = 16;
const MULTILINE: i64 = 32;
const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"];
const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
/// Where -ATTDEF is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Modes,
Tag,
Prompt,
Value,
Point,
Justify,
Style,
Height,
Rotation,
}
pub(super) struct AttdefM {
ask: Ask,
flags: i64,
tag: String,
prompt: String,
value: String,
justify: (HAlign, VAlign),
style: String,
at: Vec2,
height: f64,
}
impl Default for AttdefM {
fn default() -> Self {
AttdefM {
ask: Ask::Modes,
flags: LOCK,
tag: String::new(),
prompt: String::new(),
value: String::new(),
justify: (HAlign::Left, VAlign::Baseline),
style: "Standard".into(),
at: Vec2::ZERO,
height: 0.0,
}
}
}
impl AttdefM {
fn echo_modes(&self, s: &mut Session) {
let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" };
s.echo("Current attribute modes:");
s.echo(format!(
"Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}",
yn(INVISIBLE),
yn(CONSTANT),
yn(VERIFY),
yn(PRESET),
yn(LOCK),
yn(MULTILINE)
));
}
fn echo_style(&self, s: &mut Session) -> Result<()> {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify)));
Ok(())
}
fn point_prompt(&self) -> &'static str {
match self.justify {
(HAlign::Center, VAlign::Baseline) => "Specify center point of text",
(HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline",
(HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text",
(HAlign::Left, VAlign::Top) => "Specify top-left point of text",
(HAlign::Center, VAlign::Top) => "Specify top-center point of text",
(HAlign::Right, VAlign::Top) => "Specify top-right point of text",
(HAlign::Left, VAlign::Middle) => "Specify middle-left point of text",
(HAlign::Right, VAlign::Middle) => "Specify middle-right point of text",
(HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text",
(HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text",
(HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text",
_ => "Specify start point of text",
}
}
fn finish(&mut self, s: &mut Session, rotation: f64) -> Result<Step> {
let (h, v) = self.justify;
let left = h == HAlign::Left && v == VAlign::Baseline;
s.add_entity(EntityKind::AttDef(Attrib {
tag: self.tag.to_uppercase(),
text: Text {
insert: v3(self.at),
align_pt: if left { None } else { Some(v3(self.at)) },
height: self.height,
value: std::mem::take(&mut self.value),
rotation,
width_factor: 1.0,
oblique: 0.0,
style: self.style.clone(),
halign: h,
valign: v,
},
invisible: self.flags & INVISIBLE != 0,
constant: self.flags & CONSTANT != 0,
prompt: std::mem::take(&mut self.prompt),
props: Default::default(),
multiline: None,
modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 },
}))?;
Ok(Step::Done)
}
}
/// The justification's name in "Current text style: … Justify: …".
fn justify_name(j: (HAlign, VAlign)) -> &'static str {
JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left")
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let d = s.doc()?;
self.flags = d.header.i64("AFLAGS", LOCK);
self.style = d.header.str("TEXTSTYLE", "Standard");
self.echo_modes(s);
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.ask {
Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"),
Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT),
Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT),
Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT),
Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT),
// Once a justification is chosen AutoCAD asks for its point alone.
Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => {
Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"])
}
Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT),
Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY),
Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()),
Ask::Height => {
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at)
}
Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Modes => match &i {
Input::Keyword(k) => {
let bit = match k.as_str() {
"Invisible" => INVISIBLE,
"Constant" => CONSTANT,
"Verify" => VERIFY,
"Preset" => PRESET,
"Lock position" => LOCK,
_ => {
s.echo(format!("{k} attributes are not available yet."));
return Ok(Step::Continue);
}
};
self.flags ^= bit;
s.doc_mut()?.header.set_i64("AFLAGS", self.flags);
self.echo_modes(s);
}
Input::Enter => self.ask = Ask::Tag,
Input::Text(_) => s.echo("Invalid option keyword."),
_ => {}
},
Ask::Tag => match &i {
// AutoCAD takes spaces in a tag here, and upper-cases it.
Input::Text(t) if !t.trim().is_empty() => {
self.tag = t.trim().to_string();
self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt };
}
Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"),
_ => {}
},
Ask::Prompt | Ask::Value => {
let t = match i {
Input::Text(t) => t,
Input::Enter => String::new(),
_ => return Ok(Step::Continue),
};
if self.ask == Ask::Prompt {
self.prompt = t;
self.ask = Ask::Value;
} else {
self.value = t;
self.echo_style(s)?;
self.ask = Ask::Point;
}
}
Ask::Point => match i {
Input::Point(p) => {
self.at = p;
self.ask = Ask::Height;
}
Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify,
Input::Keyword(_) => self.ask = Ask::Style,
_ => {}
},
Ask::Justify => {
// A justification goes on to its point; anything else is asked again.
if let Input::Keyword(k) | Input::Text(k) = &i {
match justify(k) {
// Aligned and fitted text take two points: not here yet.
Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."),
Some(j) => {
self.justify = j;
self.ask = Ask::Point;
}
None => s.echo("Invalid option keyword."),
}
}
}
Ask::Style => {
let name = match i {
Input::Enter => self.style.clone(),
Input::Text(t) | Input::Keyword(t) => t.trim().to_string(),
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect();
s.echo(format!("Text styles: {}", names.join(", ")));
return Ok(Step::Continue);
}
match s.doc()?.text_style(&name).map(|st| st.name.clone()) {
Some(n) => {
self.style = n;
self.echo_style(s)?;
self.ask = Ask::Point;
}
None => s.echo(format!("Cannot find text style \"{name}\".")),
}
}
Ask::Height => {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
let h = match &i {
Input::Enter => th,
Input::Point(p) => self.at.dist(*p),
// A distance, as TEXT takes its height (1'6" in feet and inches, too).
Input::Text(t) => number(t).unwrap_or(0.0),
_ => return Ok(Step::Continue),
};
if !(h.is_finite() && h > 0.0) {
s.echo("Requires a positive height.");
return Ok(Step::Continue);
}
self.height = h;
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
self.ask = Ask::Rotation;
}
Ask::Rotation => {
let r = match &i {
Input::Enter => 0.0,
Input::Point(p) => self.at.angle_to(*p),
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires an angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
return self.finish(s, r);
}
}
Ok(Step::Continue)
}
}
+18 -42
View File
@@ -6,7 +6,7 @@
//! (names this drawing already has keep this drawing's definitions). The file is read through the
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
}
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
fn stem(path: &str) -> String {
pub(super) fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
@@ -102,14 +102,14 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
// Redefining keeps the existing definition's name (block names are case-insensitive).
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
merge_tables(&mut d, src);
let renames = block_names(&d, src, &name);
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
@@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
let mut ents = Vec::new();
for e in src.model.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
d.ensure_layer(&e.common.layer);
ents.push(e);
}
@@ -136,13 +136,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition).
fn merge_tables(d: &mut Drawing, src: &Drawing) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
use crate::cmd::merge::merge_named as merge;
merge(&mut d.layers, &src.layers, |l| &l.name);
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
@@ -152,40 +146,22 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has (or the block being defined) keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
taken.insert(defining.to_ascii_uppercase());
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
let old = e.handle;
e.handle = d.new_handle();
// An attribute definition keeps its field: its extension dictionary and the objects in it.
if matches!(e.kind, EntityKind::AttDef(_))
&& let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict)
{
let tree = src.foreign.tree(root);
let fresh: Vec<Handle> = (0..tree.len() + 2).map(|_| d.new_handle()).collect();
let foreign = Arc::make_mut(&mut d.foreign);
foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle);
foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied();
}
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
+51 -1
View File
@@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.collect()
}
Kind::Linetypes => {
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
// Objects and layers use linetypes, and so do dimension styles and a dimension's own
// overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements.
let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]);
let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| {
["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string))
});
let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone()));
let used = lower(
&mut entities(d)
.map(|e| e.common.linetype.clone())
.chain(d.layers.iter().map(|l| l.linetype.clone()))
.chain(styles)
.chain(overrides)
.chain(mlines),
);
let cur = d.header.str("CELTYPE", "ByLayer");
d.linetypes
.iter()
@@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.style.clone()),
EntityKind::Tolerance(t) => Some(t.style.clone()),
EntityKind::Leader(l) => Some(l.style.clone()),
_ => None,
}));
let cur = d.header.str("DIMSTYLE", "Standard");
@@ -299,6 +314,41 @@ fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
v.len() != before
}
/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested
/// block, its layer, that layer's linetype).
const MAX_PASSES: usize = 64;
/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included;
/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one
/// being edited: nothing goes through the session or its undo.
pub(in crate::cmd) fn purge_unused(d: &mut Drawing) {
for _ in 0..MAX_PASSES {
let mut removed = false;
for kind in ALL {
for name in candidates(d, *kind) {
removed |= match kind {
Kind::Blocks => {
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned();
key.is_some_and(|k| d.blocks.remove(&k).is_some())
}
Kind::Layers => retain(&mut d.layers, |l| &l.name, &name),
Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name),
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name),
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name),
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name),
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name),
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name),
Kind::Groups => retain(&mut d.groups, |g| &g.name, &name),
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
};
}
}
if !removed {
break;
}
}
}
/// Model-space and layout objects with no length, or text with no characters.
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
let zero = |e: &EntityKind| match e {
+2
View File
@@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result<Value> {
}
nd.end_refedit_discard(&r);
*s.doc_mut()? = nd;
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
s.state_mut()?.erased.forget(|h| r.owns(h));
s.set_selection(Vec::new());
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
}
+353
View File
@@ -0,0 +1,353 @@
//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing
//! written to a drawing file.
use std::collections::BTreeMap;
use cadcraft_doc::{Drawing, Entity, Handle};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use serde_json::{Value, json};
use super::super::curves::KW;
use super::super::file::{drops_unknown, new_folder, not_saved, not_saved_message, report_new_folder, report_not_saved};
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::modify::erase_kept;
use super::super::{CommandSpec, bad, point_param, str_param, targets};
use super::insert_file::stem;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn wblock_spec() -> CommandSpec {
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}")
}
/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and
/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE.
pub(super) fn dash_wblock_spec() -> CommandSpec {
CommandSpec::new("-wblock", "Write Block", run_dash_wblock)
.params("{path, name? | handles?, base?} (the objects written are erased)")
.interactive(|_| Ok(Box::new(DashWblockM::default())))
}
/// The web build has no file system: -WBLOCK would erase objects it can't save.
const NO_FILES: &str = "files can't be written in the web build";
/// What WBLOCK writes.
enum Source<'a> {
/// These objects, `base` becoming the written drawing's origin.
Objects(&'a [Handle], Vec2),
/// A block's objects, from its base point or `base` (in block coordinates).
Block(&'a str, Option<Vec2>),
/// The whole drawing.
Whole,
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
if cfg!(target_arch = "wasm32") {
return Err(bad("-wblock", NO_FILES));
}
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
None => {
let hs = targets(s, p)?;
// As at the command line, nothing selected writes no file.
if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) {
return Err(bad("-wblock", "no objects selected"));
}
let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?;
erase(s, &hs)?;
written
}
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a
/// DWG file left out, for the caller to report as a save does. The
/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units
/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and
/// scaled on insert, and its defaults (text size, current styles, annotation scales).
fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap<String, usize>)> {
let src = s.doc()?;
// The base point becomes the written drawing's origin: by default 0,0 for objects and the
// block's base point for a block.
let d = match what {
Source::Whole => src.clone(),
Source::Block(name, base) => {
let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
with_objects(src, b.entities.iter().map(|e| moved(e, &m)))
}
Source::Objects(hs, base) => {
let m = Mat3::translate(-base);
with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m)))
}
};
let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
#[cfg(not(target_arch = "wasm32"))]
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
Ok((d.model.len(), bytes.len(), lost))
}
/// Say on the command line what the written DWG file left out.
fn echo_not_saved(s: &mut Session, lost: &BTreeMap<String, usize>) {
if let Some(msg) = not_saved_message(lost) {
s.echo(msg);
}
}
/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks
/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and
/// the defaults and the current ones stay. Its insertion base is 0,0.
fn with_objects(src: &Drawing, ents: impl Iterator<Item = Entity>) -> Drawing {
let mut d = Drawing::new_imperial();
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
// The copied blocks keep their objects' handles, so the new ones start above them.
let kept: Vec<Handle> = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect();
for h in kept {
d.bump_handseed(h);
}
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
super::purge::purge_unused(&mut d);
d
}
fn moved(e: &Entity, m: &Mat3) -> Entity {
let mut e = e.clone();
e.kind.transform(m);
e
}
fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> {
erase_kept(s, hs)?;
s.set_selection(Vec::new());
Ok(())
}
/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with
/// .dwg added, as AutoCAD names a drawing.
fn output_path(typed: &str) -> String {
let t = typed.trim().trim_matches('"');
let lower = t.to_ascii_lowercase();
if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") }
}
/// Whether a file is there to replace; the web has no file system.
fn exists(path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
std::path::Path::new(path).exists()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
false
}
}
/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
File,
/// The file exists: "… already exists, do you want to replace it? [Yes/No] <N>".
Replace,
/// A DXF file's decimal places of accuracy.
Precision,
/// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter
/// for objects.
Block,
Base,
/// mOde at the base point prompt: asked, but the objects are erased whatever it says.
Mode,
Objects,
}
const SAME_NAME: &str = "= (block=output file)";
const WHOLE: &str = "* (whole drawing)";
#[derive(Default)]
struct DashWblockM {
ask: Ask,
path: String,
base: Vec2,
sel: SelectPhase,
}
impl Interactive for DashWblockM {
fn name(&self) -> &'static str {
"WBLOCK"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if cfg!(target_arch = "wasm32") {
s.echo(format!("-WBLOCK: {NO_FILES}."));
return Ok(Step::Cancel);
}
s.echo("Current settings: Object conversion=Delete");
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::File => Prompt::new("Enter name of output file", Accept::TEXT),
Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"),
Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"),
Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"),
Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]),
Ask::Mode => {
Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete")
}
Ask::Objects => self.sel.prompt(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::File => {
if let Input::Text(t) = &i
&& !t.trim().trim_matches('"').is_empty()
{
self.path = output_path(t);
self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) };
}
Ok(Step::Continue)
}
Ask::Replace => match &i {
// Yes goes on; No, the default, ends the command; anything else is asked again.
Input::Keyword(k) if k == "Yes" => {
self.ask = self.after_file(s);
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Enter => Ok(Step::Done),
_ => {
s.echo("Invalid option keyword.");
Ok(Step::Continue)
}
},
Ask::Precision => {
match &i {
Input::Enter => self.ask = Ask::Block,
// The file is written at full precision whatever is asked.
Input::Text(t) if t.trim().parse::<u8>().is_ok_and(|n| n <= 16) => self.ask = Ask::Block,
Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."),
Input::Keyword(_) => {}
_ => s.echo("Requires an integer between 0 and 16."),
}
Ok(Step::Continue)
}
Ask::Block => {
let name = match &i {
Input::Enter => {
self.ask = Ask::Base;
return Ok(Step::Continue);
}
Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole),
Input::Keyword(k) if k == SAME_NAME => stem(&self.path),
Input::Text(t) => t.trim().to_string(),
// A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found.
Input::Point(p) => format!("{},{}", p.x, p.y),
_ => return Ok(Step::Continue),
};
if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
return Ok(Step::Continue);
}
self.write(s, Source::Block(&name, None))
}
Ask::Base => {
match i {
Input::Keyword(_) => self.ask = Ask::Mode,
Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."),
Input::Point(p) => {
self.base = p;
self.sel = SelectPhase::begin(s);
if self.sel.done {
let picked = self.sel.picked.clone();
return self.write_objects(s, &picked);
}
self.ask = Ask::Objects;
}
_ => {}
}
Ok(Step::Continue)
}
Ask::Mode => {
// Any answer, Enter included, goes back to the base point.
self.ask = Ask::Base;
Ok(Step::Continue)
}
Ask::Objects => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
// Nothing selected: nothing is written.
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.write_objects(s, &hs),
},
}
}
}
impl DashWblockM {
/// The prompt after the file name: a DXF file's precision, else the block.
fn after_file(&self, s: &mut Session) -> Ask {
if self.path.to_ascii_lowercase().ends_with(".dxf") {
s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No");
Ask::Precision
} else {
Ask::Block
}
}
fn write(&self, s: &mut Session, what: Source) -> Result<Step> {
match write(s, "-wblock", &self.path, what) {
Ok((.., lost)) => echo_not_saved(s, &lost),
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
if hs.is_empty() {
return Ok(Step::Done);
}
match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) {
Ok((.., lost)) => {
echo_not_saved(s, &lost);
erase(s, hs)?;
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
}
+62 -12
View File
@@ -10,6 +10,7 @@
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
//! block empty with a status saying why; nothing here stops the host drawing from opening.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -208,7 +209,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
let found = files.into_iter().find(|p| p.is_file());
let found = files.iter().find(|p| p.is_file()).cloned().or_else(|| files.iter().find_map(|p| case_insensitive(p)));
#[cfg(target_arch = "wasm32")]
let found: Option<PathBuf> = {
let _ = files;
@@ -217,6 +218,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
/// Upper bound on directory entries scanned per path component by [`case_insensitive`].
#[cfg(not(target_arch = "wasm32"))]
const MAX_CASE_SCAN: usize = 10_000;
/// The existing file `p` names when its letters' case is ignored, on file systems that don't
/// ignore it themselves. Drawings made on Windows often store a reference as `PLAN.DWG` for
/// `Plan.dwg`; Windows finds it, and so should Linux. Each missing component is matched against
/// its folder's entries; an exact match always wins, and an ambiguous one finds nothing.
#[cfg(not(target_arch = "wasm32"))]
fn case_insensitive(p: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
let Component::Normal(name) = c else {
out.push(c.as_os_str());
continue;
};
let exact = out.join(name);
if exact.exists() {
out = exact;
continue;
}
let want = name.to_str()?.to_lowercase();
let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out })
.ok()?
.take(MAX_CASE_SCAN)
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want));
let hit = hits.next()?;
if hits.next().is_some() {
return None;
}
out.push(hit.file_name());
}
out.is_file().then_some(out)
}
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
@@ -302,14 +340,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
Ok((x, file)) => match import(d, &key, &x, cx) {
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
Err(e) => (XrefStatus::Unresolved, e),
Err(e) => {
// Whatever made it in before the failure goes again. A fetch that failed imported
// nothing, so it doesn't pay for a second scan of the block table.
clear(d, &key);
(XrefStatus::Unresolved, e)
}
},
Err(e) => e,
};
if status != XrefStatus::Loaded {
// Whatever made it in before the failure goes again.
clear(d, &key);
}
set_status(d, &key, status);
(status, detail)
}
@@ -353,9 +392,15 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
}
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
// The host's names per table, looked up in a set built once: both drawings are hostile input,
// so a scan per imported record would be quadratic.
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
}
let mut have = names(&d.layers, |l| &l.name);
for l in &x.layers {
if let Some(n) = dep(Sym::Layer, &l.name)
&& d.layer(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
let mut l = l.clone();
l.name = n;
@@ -363,39 +408,44 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
d.layers.push(l);
}
}
let mut have = names(&d.linetypes, |t| &t.name);
for t in &x.linetypes {
if let Some(n) = dep(Sym::Linetype, &t.name)
&& d.linetype(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
}
}
let mut have = names(&d.text_styles, |t| &t.name);
for t in &x.text_styles {
if let Some(n) = dep(Sym::Style, &t.name)
&& d.text_style(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
}
}
let mut have = names(&d.dim_styles, |t| &t.name);
for t in &x.dim_styles {
if let Some(n) = dep(Sym::DimStyle, &t.name)
&& d.dim_style(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
}
}
let mut have = names(&d.mleader_styles, |t| &t.name);
for t in &x.mleader_styles {
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
&& have.insert(n.to_ascii_uppercase())
{
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
}
}
let mut have = names(&d.table_styles, |t| &t.name);
for t in &x.table_styles {
if let Some(n) = dep(Sym::TableStyle, &t.name)
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
&& have.insert(n.to_ascii_uppercase())
{
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
}
+3 -37
View File
@@ -10,7 +10,7 @@
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
//! end can put it on the system clipboard and paste it into another CADCraft window.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
@@ -279,20 +279,14 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
/// their name in `defs` to their name in `d`.
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
use crate::cmd::merge::merge_named as add;
add(&mut d.layers, &defs.layers, |l| &l.name);
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
let renames = block_names(d, defs);
let renames = crate::cmd::merge::block_renames(d, defs, None);
for (from, to) in &renames {
let Some(b) = defs.blocks.get(from) else { continue };
let mut nb = (**b).clone();
@@ -308,34 +302,6 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
renames
}
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
/// already has keeps `d`'s definition (not copied).
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
for (name, b) in &defs.blocks {
let upper = up(name);
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(up(&to));
out.insert(name.clone(), to);
}
out
}
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
/// objects of the source drawing (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
+1
View File
@@ -33,6 +33,7 @@ use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
use polygon::{PolygonM, run_polygon};
use solid::{SolidM, run_solid};
use spline::{SplineM, run_spline};
pub(crate) use text::justify;
use text::{MTextM, TextM, run_mtext, run_text};
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
+6 -8
View File
@@ -153,19 +153,17 @@ impl Interactive for HelixM {
}
match (self.center, self.rb, self.rt) {
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
(Some(c), None, _) => {
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c)
}
(Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c),
(Some(c), Some(rb), None) => {
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default(format!("{rb:.4}"))
.base(c)
}
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER)
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
.default("1.0000")
.base(c),
+74 -4
View File
@@ -2,6 +2,7 @@ use super::{
Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly,
number, other, point_param, point_req, rect_with_corners, size_param,
};
use crate::last_used::RectangModes;
// =====================================================================================
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
@@ -115,11 +116,58 @@ pub(crate) struct RectM2 {
asking: Option<RAsk>,
}
/// A rectangle's rotation as AutoCAD 2024 shows it: degrees from 0 to 360, two decimals.
fn rotation_text(rotation: f64) -> String {
let t = format!("{:.2}", rotation.to_degrees().rem_euclid(360.0));
if t == "360.00" { "0.00".into() } else { t }
}
/// AutoCAD 2024's line listing the RECTANG modes that are set, `None` when none is ("Current
/// rectangle modes: Chamfer=2.2518 x 3.7663 Rotation=50.50"): distances in the drawing's
/// units, the rotation as [`rotation_text`] shows it.
fn modes_line(s: &Session, m: &RectangModes) -> Option<String> {
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
let d = |v: f64| crate::units::format_distance(v, u, p);
let mut parts = Vec::new();
if m.chamfer.0 > 0.0 || m.chamfer.1 > 0.0 {
parts.push(format!("Chamfer={} x {}", d(m.chamfer.0), d(m.chamfer.1)));
}
if m.fillet > 0.0 {
parts.push(format!("Fillet={}", d(m.fillet)));
}
if m.elevation != 0.0 {
parts.push(format!("Elevation={}", d(m.elevation)));
}
if m.thickness != 0.0 {
parts.push(format!("Thickness={}", d(m.thickness)));
}
if m.width > 0.0 {
parts.push(format!("Width={}", d(m.width)));
}
let rot = rotation_text(m.rotation);
if rot != "0.00" {
parts.push(format!("Rotation={rot}"));
}
(!parts.is_empty()).then(|| format!("Current rectangle modes: {}", parts.join(" ")))
}
impl RectM2 {
/// The modes the next rectangle starts from.
fn modes(&self) -> RectangModes {
RectangModes {
chamfer: self.chamfer,
fillet: self.fillet,
width: self.width,
elevation: self.elevation,
thickness: self.thickness,
rotation: self.rotation,
}
}
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
/// the centre when centre mode is on, then applies the requested thickness.
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
let Some(a) = self.first else { return Ok(Step::Cancel) };
s.last_used.rectang = self.modes();
if w.abs() < 1e-12 || h.abs() < 1e-12 {
return Err(other("The rectangle has no area."));
}
@@ -155,6 +203,20 @@ impl Interactive for RectM2 {
fn name(&self) -> &'static str {
"RECTANG"
}
/// Starts from the modes the last rectangle left, and lists the ones that are set.
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let m = s.last_used.rectang;
self.chamfer = m.chamfer;
self.fillet = m.fillet;
self.width = m.width;
self.elevation = m.elevation;
self.thickness = m.thickness;
self.rotation = m.rotation;
if let Some(line) = modes_line(s, &m) {
s.echo(line);
}
Ok(Step::Continue)
}
/// Asks for the pending option's value, or for the first and second points; in centre mode
/// the first prompt asks for the centre instead of a corner.
fn prompt(&self, _s: &Session) -> Prompt {
@@ -173,9 +235,9 @@ impl Interactive for RectM2 {
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
RAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER)
.kw(&["Pick points"])
.default(format!("{:.0}", self.rotation.to_degrees()))
.default(rotation_text(self.rotation))
.base_opt(self.first),
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
@@ -207,8 +269,14 @@ impl Interactive for RectM2 {
self.chamfer.1 = self.chamfer.0;
self.asking = Some(RAsk::Chamfer2);
}
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
RAsk::Chamfer2 => {
self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0);
self.fillet = 0.0;
}
RAsk::Fillet => {
self.fillet = val(&i, self.fillet)?.max(0.0);
self.chamfer = (0.0, 0.0);
}
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
@@ -260,6 +328,8 @@ impl Interactive for RectM2 {
}
}
}
// A mode is kept as soon as it is set, even if no rectangle follows.
s.last_used.rectang = self.modes();
return Ok(Step::Continue);
}
match i {
+2 -1
View File
@@ -996,7 +996,8 @@ fn rectang_options_command_line() {
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
let pts = rect_pts(&s);
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
// RECTANG keeps the rotation of the rectangle before (45 degrees), as AutoCAD does: back to 0.
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 r 0 4,4").unwrap();
match last(&s) {
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
k => panic!("{k:?}"),
+2 -2
View File
@@ -119,7 +119,7 @@ impl Interactive for XlineM2 {
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
},
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]),
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
XMode::Bisect => match self.bis.len() {
@@ -129,7 +129,7 @@ impl Interactive for XlineM2 {
},
XMode::OffDist => {
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
"Through".into()
} else {
format!("{d:.4}")
+1 -7
View File
@@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
let mut hs = s.selection();
hs.retain(|h| !super::curves::is_locked(s, *h));
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
if d.remove_entity(h).is_some() {
n += 1;
}
}
let n = super::modify::erase_kept(s, &hs)?;
s.set_selection(Vec::new());
Ok(json!({ "erased": n }))
}
+177 -53
View File
@@ -7,7 +7,7 @@
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
//! commands come from a menu, the tool bar or the command line).
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::sync::Arc;
use cadcraft_color::Rgb;
@@ -82,7 +82,7 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -117,14 +117,19 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
if !EXPORT_FORMATS.contains(&format.as_str()) {
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
}
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
match path {
let file = format!("export.{format}");
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
let folder = path.and_then(super::file::new_folder);
let mut r = match path {
Some(path) => {
write_file("export", path, &bytes)?;
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })
}
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
}
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
};
super::file::report_new_folder(s, &mut r, folder);
super::file::report_loss(s, &mut r, &file);
Ok(r)
}
// ---------- IMPORT ----------
@@ -161,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
let space = s.space();
let d = s.doc_mut()?;
let (layers, styles) = merge_tables(d, src);
let renames = block_names(d, src);
let renames = crate::cmd::merge::block_renames(d, src, None);
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
@@ -208,16 +213,7 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
let mut n = 0;
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
n += 1;
}
}
n
}
use crate::cmd::merge::merge_named as merge;
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
@@ -227,35 +223,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
(layers, others)
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let anonymous = b.anonymous || name.starts_with('*');
let to = if !taken.contains(&upper) {
name.clone()
} else if anonymous {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
@@ -330,17 +297,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
// A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493).
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot.
let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none();
let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() };
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
// A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF.
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none()));
let scale = given_scale.or(saved.scale);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
let px_per_paper_unit = width as f64 / sheet.size.x;
// Curves are flattened to about half a pixel.
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale);
let tol = 0.5 / (px_per_paper_unit * est);
let ropts = cadcraft_render::Options {
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
@@ -352,9 +325,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
let b = list.bounds;
let b = saved.window.unwrap_or(list.bounds);
// Paper point q shows drawing point (q - to) / k + from.
let (from, k, to) = if fit || space == Space::Model {
let (from, k, to) = if saved.window.is_some() {
// A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's.
let c = saved.center;
(if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min })
} else if fit || space == Space::Model {
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
} else {
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
@@ -372,7 +349,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
// A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it.
if fit || saved.window.is_some() || space == Space::Model {
// The view may scale and centre the sheet (the height cap), so map the printable corners
// (paper point q shows drawing point (q - to) / k + from) through it.
let screen = |q: Vec2| {
let (x, y) = view.to_screen((q - to) / k + from);
(f64::from(x), f64::from(y))
};
let (min, max) = (sheet.printable.min, sheet.printable.max);
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
}
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
let layout = space.tab_name();
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
@@ -738,6 +728,140 @@ mod tests {
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
}
/// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as
/// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493).
#[test]
fn preview_uses_the_layouts_saved_page_setup_by_default() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 836.0, 589.0];
page.scale_to_fit = true;
let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview");
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet");
}
/// With a saved plot window the preview follows the page setup's centring (fitted or not) and an
/// explicit `scale` beats a saved scale to fit, as in the PDF.
/// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it:
/// geometry in the margin leaves the preview unchanged.
#[test]
fn preview_clips_a_saved_window_to_the_printable_area() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 60.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away");
}
/// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the
/// printable clip must follow it, so ink in the margin is still blanked.
#[test]
fn preview_clip_follows_the_view_when_the_height_cap_applies() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.width_mm = 210.0;
page.height_mm = 1200.0;
page.landscape = false;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 100.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet");
}
/// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back
/// to the plain preview and never panic, and a layout with no saved setup previews exactly as
/// the plain 1:1 sheet does.
#[test]
fn preview_survives_hostile_saved_setups_and_default_is_unchanged() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let call = json!({ "layout": "Layout1", "width": 200 });
let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false }));
assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet");
let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| {
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = window;
page.scale = scale;
page.scale_to_fit = fit;
};
let good = [0.0, 0.0, 100.0, 60.0];
let (nan, inf) = (f64::NAN, f64::INFINITY);
for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] {
set(&mut s, window, 1.0, true);
assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing");
}
for scale in [0.0, -2.0, nan, inf] {
set(&mut s, good, scale, false);
let _ = png(&mut s, call.clone());
set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false);
assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored");
}
set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true);
let _ = png(&mut s, call.clone());
set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true);
let _ = png(&mut s, call.clone());
}
#[test]
fn preview_honours_saved_centering_and_an_explicit_scale() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 400.0, 200.0];
page.scale_to_fit = true;
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c;
set_center(&mut s, true);
let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
set_center(&mut s, false);
// The window is fitted by width, so only the other axis shows where it sits.
let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner");
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.center = true;
let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 }));
assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot");
assert_eq!(
by_scale,
png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })),
"scale decides, not the saved fit"
);
}
#[test]
fn pdfbook_collects_frames_into_one_pdf_per_row() {
our_hooks();
+359 -20
View File
@@ -85,12 +85,13 @@ pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
/// binary instead of ASCII DXF.
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveAs {
pub version: Option<Version>,
pub binary: bool,
pub portable_fonts: bool,
}
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
@@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
};
Ok(SaveAs { version, binary })
let portable_fonts = match p.get("portableFonts") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
};
Ok(SaveAs { version, binary, portable_fonts })
}
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
@@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
let d = copy.as_ref().unwrap_or(d);
let r = if opts.version.is_none() && !opts.binary {
(hooks.write)(d, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
@@ -178,11 +186,13 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
.menu(&["File", "Save As..."])
.key("Cmd+Shift+S")
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
.params(
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
)
.noundo(),
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
@@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
}
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
save_as(s, "save", path, &opts)
}
@@ -420,14 +430,24 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
Ok(n)
}
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
/// Put saved bytes where the user gets them: natively the file at `path`; on the web, which has
/// no file system, a browser download named after `path` via [`deliver`].
///
/// Natively the file is replaced atomically ([`crate::atomic_write`]: a fresh temporary file
/// beside it, renamed over it), so a crash leaves either the old file or the new one, never a
/// truncated one. Saving over a drawing first keeps the previous file as `<name>.bak` (ISAVEBAK,
/// #540).
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
// A file in a folder still to be made has nothing to back up.
if is_drawing_file(path) && crate::autosave::isavebak() && crate::atomic_write::missing_folder(std::path::Path::new(path)).is_none() {
backup(std::path::Path::new(path))
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
}
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -437,6 +457,191 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
}
}
/// The backup of the drawing file `path`, beside it. A DWG's is `<name>.bak`, as AutoCAD names it,
/// in the extension's letter case (`plan.DWG` to `plan.BAK`); any other drawing appends `.bak`
/// (`plan.dxf.bak`), so `plan.dwg` and `plan.dxf` in one folder never overwrite each other's backup.
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
match path.extension().map(|e| e.to_string_lossy()) {
Some(e) if e.eq_ignore_ascii_case("dwg") => path.with_extension(
e.chars().zip("bak".chars()).map(|(d, b)| if d.is_ascii_uppercase() { b.to_ascii_uppercase() } else { b }).collect::<String>(),
),
_ => {
let mut name = path.as_os_str().to_os_string();
name.push(".bak");
name.into()
}
}
}
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a
/// good `.bak`.
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
if dwg || cadcraft_dxf::is_binary(bytes) {
return true;
}
// ASCII DXF: optional comments (999), then group 0 SECTION.
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
loop {
match (lines.next(), lines.next()) {
(Some("999"), Some(_)) => continue,
(Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"),
_ => return false,
}
}
}
/// How much of a drawing's end [`looks_complete`] reads.
#[cfg(not(target_arch = "wasm32"))]
const TAIL_BYTES: u64 = 256;
/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them
/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it
/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its
/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut
/// off by a failed save or another program doesn't replace a good backup.
pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit());
if dwg {
// Too short for any DWG's header: cut off, not unknown.
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
}
if cadcraft_dxf::is_binary(head) {
return Some(tail.ends_with(b"EOF\0"));
}
let text = String::from_utf8_lossy(tail);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev();
Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF")))
}
/// [`completeness`] known to be whole.
pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool {
completeness(head, tail, len) != Some(false)
}
/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for
/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in
/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section
/// the locator records give. `None` for another layout (2007, or before R13).
fn dwg_complete(head: &[u8], len: u64) -> Option<bool> {
let version = head.get(..6)?;
match version {
b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => {
// The header at 0x80 is XORed with the generator the specification gives.
let enc = head.get(0x80..0x80 + 0x6C)?;
let mut seed: u32 = 1;
let h: Vec<u8> = enc
.iter()
.map(|b| {
seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3);
b ^ (seed >> 16) as u8
})
.collect();
if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) {
return Some(false);
}
let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?);
Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end))
}
b"AC1012" | b"AC1014" | b"AC1015" => {
let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?);
if !(1..=16).contains(&n) {
return Some(false);
}
let mut end = 0u64;
for i in 0..n as usize {
let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?;
let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?));
let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?));
end = end.max(seek + size);
}
Some(len >= end)
}
_ => None,
}
}
/// A file's first bytes, last bytes and length, left positioned just after the first bytes.
#[cfg(not(target_arch = "wasm32"))]
fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec<u8>, Vec<u8>, u64)> {
use std::io::{Read, Seek, SeekFrom};
let mut head = Vec::with_capacity(4096);
(&mut *src).take(4096).read_to_end(&mut head)?;
// Its end too (a DXF's EOF), then on from where the start left off for the copy.
let len = src.metadata()?.len();
let mut tail = Vec::new();
src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
(&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?;
src.seek(SeekFrom::Start(head.len() as u64))?;
Ok((head, tail, len))
}
/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a
/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it
/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong
/// about a real file and this save replaces the only copy.
#[cfg(not(target_arch = "wasm32"))]
fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool {
(looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists)
}
/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that
/// must not promise a backup the save won't write.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_keeps_current(path: &std::path::Path) -> bool {
let Ok(mut src) = std::fs::File::open(path) else { return false };
crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists()))
}
#[cfg(target_arch = "wasm32")]
pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone.
#[cfg(not(target_arch = "wasm32"))]
fn backup(path: &std::path::Path) -> std::io::Result<()> {
let mut src = match std::fs::File::open(path) {
Ok(f) if f.metadata()?.is_file() => f,
Ok(_) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let (head, tail, len) = ends(&mut src)?;
let bak = backup_path(path);
if !backup_replaced(&head, &tail, len, bak.exists()) {
log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display());
return Ok(());
}
// The backup is no more readable than the drawing, nor than the .bak it replaces (a private
// 0600 drawing or .bak stays private). The mode is set as the new file is created, so a
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
};
#[cfg(not(unix))]
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
}
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
@@ -483,6 +688,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
if is_drawing_file(&path) {
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
r["binary"] = json!(opts.binary);
r["portableFonts"] = json!(opts.portable_fonts);
}
Ok(r)
}
@@ -490,8 +696,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
fn drawing_version(s: &Session, path: &str) -> Version {
let v =
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
let v = s
.state()
.ok()
.and_then(|st| st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver))
.unwrap_or_default();
if is_dxf(path) { v } else { v.max(Version::R2004) }
}
@@ -520,6 +729,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if drops_unknown(format!("drawing.{fmt}")) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) {
report_sat_text_dropped(s, &mut r);
}
Ok(r)
}
@@ -537,15 +748,28 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
/// store section, which is written back with the drawing (`Foreign::data_store`).
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads
/// them from our DXF into entities of its own and writes them to DWG of every version we save,
/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the
/// bridge gets the type as stored, and acadrust knows only this spelling of it.
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted.
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`].
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
// A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be
// in the data store, and SAT text alone is lost (#2334).
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t))
&& (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags)))
{
continue;
}
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
@@ -561,7 +785,37 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
return None;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
Some(format!(
"Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}",
list.join(", ")
))
}
/// What saving the active drawing to `path` would leave out, before anything is written: empty
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
/// reports the same list afterwards to callers that never get a dialog.
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
let version = drawing_version(s, path);
if is_dxf_file(path) && version >= Version::R2013 {
// The drawing a file holds: the host while the Block Editor is open.
return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
}
if !drops_unknown(path) {
return Default::default();
}
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
}
/// Report what the file `path`, just written at the drawing's own version, left out.
pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) {
if is_dxf_file(path) {
if drawing_version(s, path) >= Version::R2013 {
report_sat_text_dropped(s, r);
}
} else {
let lost = loss_on_save(s, path);
report_not_saved(s, r, &lost);
}
}
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
@@ -575,8 +829,53 @@ fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path).is_some_and(Format::is_drawing)
}
/// Whether `path` is a DXF file.
fn is_dxf_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path) == Some(Format::Dxf)
}
/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because
/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334).
fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
// The drawing a file holds: the host while the Block Editor is open.
let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
if lost.is_empty() {
return;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
s.echo(format!(
"Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}",
list.join(", ")
));
if let Some(o) = r.as_object_mut() {
o.insert("notSaved".into(), json!(lost));
}
}
/// The folder a write to `path` has to create, asked before writing so it can be reported after.
/// The web has no folders.
pub(super) fn new_folder(path: &str) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
return crate::atomic_write::missing_folder(std::path::Path::new(path)).map(|f| f.display().to_string());
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
}
/// Say on the command line that the file went into a folder that didn't exist, so a mistyped path
/// is noticed, and name the folder in the result's `createdFolder`.
pub(super) fn report_new_folder(s: &mut Session, r: &mut Value, folder: Option<String>) {
let Some(folder) = folder else { return };
s.echo(format!("Created the folder {folder}, which did not exist. Check the path if you did not mean to."));
if let Some(o) = r.as_object_mut() {
o.insert("createdFolder".into(), json!(folder));
}
}
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
let Some(msg) = not_saved_message(lost) else { return };
s.echo(msg);
if let Some(o) = r.as_object_mut() {
@@ -585,24 +884,64 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
}
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, asked: Option<Version>) -> Result<Value> {
// What the file will hold depends on the version it is saved as (the data store from 2013).
// QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for.
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let folder = new_folder(&path);
let mut r = run(s, p)?;
report_new_folder(s, &mut r, folder);
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
report_sat_text_dropped(s, &mut r);
}
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
report_fonts(s, &mut r);
}
Ok(r)
}
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
/// result, and at the command line when the list changed since the last save. Styles from an xref
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
fn report_fonts(s: &mut Session, r: &mut Value) {
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
let fonts: Vec<(String, String)> = d
.text_styles
.iter()
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
.map(|t| (t.name.clone(), t.font.clone()))
.collect();
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
if fonts.is_empty() {
return;
}
if changed {
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
" SAVEAS with portableFonts writes Windows fonts for those that have one."
} else {
""
};
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
}
if let Some(o) = r.as_object_mut() {
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
}
}
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_save)
save_reporting(s, p, run_save, None)
}
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_saveas)
let asked = save_options("saveas", p)?.version;
save_reporting(s, p, run_saveas, asked)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+7 -11
View File
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use cadcraft_doc::color::{Color, Rgb};
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
use serde_json::{Value, json};
use super::super::super::{CommandSpec, bad};
@@ -197,6 +197,7 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
d.skipped_records.clear();
d.handseed = d.handseed.max(other.handseed);
use crate::cmd::merge::merge_named as merge;
merge(&mut d.layers, &other.layers, |t| &t.name);
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
@@ -235,22 +236,17 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
}
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
for t in from {
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
to.push(t.clone());
}
}
}
/// Add copies of `objs` to model space with new handles, their block references renamed by
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
let mut members = Vec::new();
// The layers this drawing has, looked up once per object rather than scanned.
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
for (e, color) in objs {
d.ensure_layer(&e.common.layer);
if layers.insert(e.common.layer.to_ascii_uppercase()) {
d.layers.push(Layer::new(&e.common.layer));
}
let h = d.new_handle();
let mut copy = Entity { handle: h, ..(*e).clone() };
detach(&mut copy.kind);
+1
View File
@@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
mod assoc;
pub(crate) use assoc::detach;
mod edit;
mod pick;
use super::has_doc;
+1 -1
View File
@@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size)
}
pub(super) fn detach(h: &mut Hatch) {
pub(crate) fn detach(h: &mut Hatch) {
h.associative = false;
h.loops.iter_mut().for_each(|l| l.sources.clear());
}
+26 -36
View File
@@ -34,7 +34,9 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed:
let r = d.len();
let a0 = d.angle();
let step = cadcraft_geom::TAU / n as f64;
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) };
// Circumscribed: the picked point is an edge's midpoint and the first vertex is the end of
// that edge counter-clockwise from it, as AutoCAD 2024 numbers them.
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 + step / 2.0) };
(0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
}
@@ -62,43 +64,31 @@ pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec<PolyVertex> {
pts.into_iter().map(PolyVertex::new).collect()
}
/// Rectangle vertices with fillet radius or chamfer distances at every corner.
/// Rectangle vertices from corner `a` to the opposite corner `b`, with a fillet radius or
/// chamfer distances at every corner, in AutoCAD 2024's order: from `a` along X to `b`'s X, then
/// `b`, then back along Y. A chamfer cuts the edge into each corner by its first distance and the
/// edge out of it by its second; each fillet arc starts on the edge into its corner. A fillet or
/// chamfer that doesn't fit leaves the corners square.
pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec<PolyVertex> {
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
let w = x1 - x0;
let h = y1 - y0;
if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 {
let r = fillet;
let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2);
return vec![
PolyVertex::new(Vec2::new(x0 + r, y0)),
PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul),
PolyVertex::new(Vec2::new(x1, y0 + r)),
PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul),
PolyVertex::new(Vec2::new(x1 - r, y1)),
PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul),
PolyVertex::new(Vec2::new(x0, y1 - r)),
PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul),
];
let corners = [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)];
let short = (b.x - a.x).abs().min((b.y - a.y).abs());
let (d_in, d_out, bulge) = if fillet > 0.0 && fillet * 2.0 <= short + 1e-12 {
// Counter-clockwise round the corners (a positive bulge) when `b` is up and right of `a`, or
// down and left.
let sign = if (b.x - a.x) * (b.y - a.y) >= 0.0 { 1.0 } else { -1.0 };
(fillet, fillet, sign * cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2))
} else if chamfer.0 > 0.0 && chamfer.1 > 0.0 && chamfer.0 + chamfer.1 <= short + 1e-12 {
(chamfer.0, chamfer.1, 0.0)
} else {
return corners.into_iter().map(PolyVertex::new).collect();
};
let mut out = Vec::with_capacity(8);
for (i, cur) in corners.iter().enumerate() {
let (Some(prev), Some(next)) = (corners.get((i + 3) % 4), corners.get((i + 1) % 4)) else { continue };
out.push(PolyVertex::with_bulge(*cur + (*prev - *cur).normalized() * d_in, bulge));
out.push(PolyVertex::new(*cur + (*next - *cur).normalized() * d_out));
}
let (c1, c2) = chamfer;
if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 {
return [
Vec2::new(x0 + c1, y0),
Vec2::new(x1 - c1, y0),
Vec2::new(x1, y0 + c2),
Vec2::new(x1, y1 - c2),
Vec2::new(x1 - c1, y1),
Vec2::new(x0 + c1, y1),
Vec2::new(x0, y1 - c2),
Vec2::new(x0, y0 + c2),
]
.into_iter()
.map(PolyVertex::new)
.collect();
}
rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1))
out
}
// ---------- the user's support files ----------
+44 -3
View File
@@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
if !d.len().is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.distance = d.len();
s.last_point = a.xy();
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
@@ -285,6 +290,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
// Drawn as its faces' visible edges: the Z on the nearest one.
EntityKind::PolyfaceMesh(m) => m
.faces3d()
.iter()
.flat_map(cadcraft_doc::Face3d::visible_edges)
.map(|(a, b)| on_segment(a, b))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z),
_ => None,
}
}
@@ -422,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(what) = too_large(a, per) {
return Err(bad("area", format!("the {what} is too large")));
}
s.area = a;
s.perimeter = per;
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
}
@@ -508,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
total
}
EntityKind::Region(r) => (r.area(), r.perimeter()),
// Like an open polyline: the area closed by the chord, the perimeter its own length.
EntityKind::Spline(sp) if sp.is_valid() => {
let b = sp.bounds();
let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12));
// A length past f64's range is too large to report, as a polyline's is, not 0.
(cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY))
}
_ => return Err(bad("area", "object has no area")),
})
}
@@ -574,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
// What reading the file left out, in words (a damaged DWG's unreadable object records).
"readNotes": d.read_notes,
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
@@ -864,16 +888,25 @@ impl Interactive for DistM {
self.first_source = s.picked_viewport;
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => {
(Some(first), Input::Point(b)) => {
let (a, b) = match (self.first_source, s.picked_viewport) {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
_ => (first, b),
};
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
if !(b - a).len().is_finite() {
// The first point stays LASTPOINT, as after a measurement.
s.last_point = first;
return Err(bad("dist", "the distance is too large"));
}
let t = dist_text(s, a, b);
for l in t.lines() {
s.echo(l.to_string());
}
// As in AutoCAD, the second point doesn't become LASTPOINT: the first stays.
s.distance = (b - a).len();
s.last_point = first;
Ok(Step::Done)
}
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
@@ -901,7 +934,11 @@ impl DistM {
.as_deref()
.or(self.multi.as_deref())
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
if !d.is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.echo(format!("Distance = {}", fmt_d(s, d)));
s.distance = d;
Ok(Step::Done)
}
}
@@ -1115,10 +1152,14 @@ impl AreaM {
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
s.echo(area_text(s, a, per));
// AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's.
s.perimeter = per;
if self.mode == AreaMode::Single {
s.area = a;
return Step::Done;
}
self.total = total;
s.area = total;
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
Step::Continue
}
+3 -1
View File
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
}
loops.retain(|l| l.vertices.len() >= 2);
let mut r = Region { loops };
r.renest();
if !r.renest() {
return Err(other("These regions are too complex to combine."));
}
Ok(Some(r))
}
+34 -5
View File
@@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Op
}
fn valid_name(n: &str) -> bool {
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
@@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn);
}
}
merge::rename_in_viewports(d, &old, &nn);
if cur.eq_ignore_ascii_case(&old) {
d.header.set_str("CLAYER", &nn);
}
@@ -407,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
let d = s.doc_mut()?;
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
for saved in st.layers {
if let Some(l) = d.layer_mut(&saved.name) {
*l = saved;
restore_layers(d, st.layers);
ok()
}
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
/// drawing's layers are looked up in an index rather than scanned per saved layer.
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
for layer in saved {
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
*l = layer;
}
}
ok()
}
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -460,6 +473,22 @@ mod tests {
use crate::Session;
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
#[test]
fn restore_layers_matches_by_name_in_linear_time() {
let mut d = cadcraft_doc::Drawing::new_imperial();
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
let mut back = cadcraft_doc::Layer::new("l7");
back.frozen = true;
saved.push(back);
let started = std::time::Instant::now();
super::restore_layers(&mut d, saved);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(d.layer("L7").is_some_and(|l| l.frozen));
assert_eq!(d.layers.len(), 20_000);
}
#[test]
fn layer_states_save_list_restore_delete() {
let mut s = Session::new();
+33
View File
@@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet<String>) {
}
}
/// Point every layout viewport's frozen list and colour overrides at a renamed layer.
pub(super) fn rename_in_viewports(d: &mut Drawing, old: &str, new: &str) {
for layout in &mut d.layouts {
let hs: Vec<Handle> = layout
.entities
.iter()
.filter(|e| match &e.kind {
EntityKind::Viewport(v) => {
v.frozen_layers.iter().any(|l| l.eq_ignore_ascii_case(old)) || v.layer_colors.iter().any(|(l, _)| l.eq_ignore_ascii_case(old))
}
_ => false,
})
.map(|e| e.handle)
.collect();
for h in hs {
layout.entities.modify(h, |e| {
if let EntityKind::Viewport(v) = &mut e.kind {
for l in &mut v.frozen_layers {
if l.eq_ignore_ascii_case(old) {
*l = new.to_string();
}
}
for (l, _) in &mut v.layer_colors {
if l.eq_ignore_ascii_case(old) {
*l = new.to_string();
}
}
}
});
}
}
}
fn lower_set(names: &[String]) -> HashSet<String> {
names.iter().map(|n| n.to_ascii_lowercase()).collect()
}
+8 -5
View File
@@ -84,11 +84,11 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["File", "Print..."])
.key("Cmd+P")
.alias(&["print"])
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.noundo(),
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
.menu(&["File", "Export to PDF..."])
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.noundo(),
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
.alias(&["ms"])
@@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
let h = d.new_handle();
store.push(Entity { handle: h, ..Entity::clone(e) });
}
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });
Ok(json!({ "name": to }))
}
@@ -840,8 +840,11 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let path = _path;
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
let folder = super::file::new_folder(path);
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages });
super::file::report_new_folder(s, &mut r, folder);
Ok(r)
}
#[cfg(target_arch = "wasm32")]
{
+2 -3
View File
@@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{_path}.cadcraft-tmp");
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes())
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
}
#[cfg(target_arch = "wasm32")]
+3 -1
View File
@@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let t = template_of(d, &name)?;
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
let folder = super::super::file::new_folder(&path);
store(&path, &bytes)?;
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
super::super::file::report_new_folder(s, &mut r, folder);
let lost = not_saved(&t);
if drops_unknown(&path)
&& let Some(msg) = not_saved_message(&lost)
@@ -124,7 +126,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
+14
View File
@@ -293,3 +293,17 @@ fn mspace_edits_model_through_viewport() {
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
assert!(s.state().unwrap().mspace.is_none());
}
/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557).
#[test]
fn plot_help_names_the_device_and_the_built_in_devices() {
for id in ["plot", "exportpdf"] {
let help = crate::find_command(id).map(|c| c.params).unwrap_or_default();
assert!(help.contains("device?"), "{id}: {help}");
}
let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default();
for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] {
assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device");
assert!(help.contains(name), "plot help lacks {name}");
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
//! paste, COMPARE export).
//!
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
//! set built once per table, and a generated block name continues from the last one allocated for
//! its prefix instead of probing every taken number again (CWE-407).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use cadcraft_doc::Drawing;
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
/// exists keeps `ours`' definition). Returns how many were added.
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
let mut added = 0;
for t in theirs {
if have.insert(name(t).to_ascii_uppercase()) {
ours.push(t.clone());
added += 1;
}
}
added
}
/// Which blocks of `src` to copy into `d`, and under which name: a new name keeps its own, a
/// generated (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named
/// block `d` already has (or `defining`, the block being defined from `src`) keeps `d`'s
/// definition (not copied).
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&str>) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
taken.extend(defining.map(str::to_ascii_uppercase));
// The next number to try per generated prefix: numbers below it are taken.
let mut next: HashMap<String, usize> = HashMap::new();
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
Some((i, n)) => {
*cursor = i + 1;
n
}
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use cadcraft_doc::{Block, Layer};
use super::*;
#[test]
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
let mut ours = vec![Layer::new("Walls")];
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
}
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
#[test]
fn anonymous_collisions_take_consecutive_numbers() {
let mut d = Drawing::new_imperial();
let mut src = Drawing::new_imperial();
for i in 1..=5000 {
let mut b = Block::new(&format!("*U{i}"));
b.anonymous = true;
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
src.blocks.insert(b.name.clone(), Arc::new(b));
}
let started = std::time::Instant::now();
let renames = block_renames(&d, &src, None);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert_eq!(renames.len(), 5000);
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
let distinct: HashSet<&String> = renames.values().collect();
assert_eq!(distinct.len(), 5000);
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
}
}
+5 -1
View File
@@ -20,6 +20,7 @@ mod break_join;
mod explode;
mod fillet;
mod offset;
mod oops;
mod select_then;
mod stretch;
mod transform;
@@ -30,6 +31,8 @@ use explode::run_explode;
pub(crate) use explode::{explode_kind, explode_with};
use fillet::{FilletM, run_chamfer, run_fillet};
use offset::{OffsetM, run_offset};
use oops::run_oops;
pub(crate) use oops::{Erased, erase_kept};
use select_then::{DisplaceM, Op, SelectThen};
use stretch::{StretchM, run_stretch};
pub(crate) use transform::transform_entities;
@@ -43,6 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["e", "delete"])
.params("{handles?: [hex]} (default: selection)")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))),
CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE, BLOCK or -WBLOCK removed)"),
CommandSpec::new("move", "Move", run_move)
.menu(&["Modify", "Move"])
.alias(&["m"])
@@ -61,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("scale", "Scale", run_scale)
.menu(&["Modify", "Scale"])
.alias(&["sc"])
.params("{handles?, base: [x,y], factor, copy?: bool}")
.params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))),
CommandSpec::new("mirror", "Mirror", run_mirror)
.menu(&["Modify", "Mirror"])
+83 -3
View File
@@ -1,19 +1,78 @@
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
// ---------------- explode / array / order / break / join ----------------
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
let g = ell.transformed(m);
let len = g.major.len();
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
})
}
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
let kinds = match &be.kind {
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
EntityKind::Ellipse(el) => {
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
vec![placed_ellipse(g, m, el.center.z)?]
}
EntityKind::LwPolyline(p)
if p.vertices.iter().any(|v| v.bulge != 0.0)
&& p.const_width == 0.0
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
{
let z = p.elevation;
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
pl.segments()
.into_iter()
.map(|s| match s {
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
})
.collect::<Option<Vec<_>>>()?
}
_ => return None,
};
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
let mut common = be.common.clone();
if matches!(be.kind, EntityKind::LwPolyline(_)) {
common.xdata.clear();
}
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
}
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
if let Some(items) = crate::cmd::array::explode_array(d, e) {
return Some(items);
}
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
if minserted(ins) {
return None;
}
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
let stretched = m.similarity_scale().is_none();
Some(
blk.entities
.iter()
.map(|be| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
.flat_map(|be| {
stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
vec![ne]
})
})
.map(|mut ne| {
if ne.common.layer == "0" {
ne.common.layer = e.common.layer.clone();
}
@@ -151,6 +210,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
})
.collect(),
),
// A polyface mesh explodes into its 3D faces; one without any stays as it is.
EntityKind::PolyfaceMesh(m) => {
let faces = m.faces3d();
(!faces.is_empty())
.then(|| faces.into_iter().map(|f| Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Face3d(f) }).collect())
}
EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)),
EntityKind::MText(m) => Some(explode_mtext(e, m)),
// A region explodes into its boundary's lines and arcs.
@@ -162,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
// without a word, as `explode_with` leaves them.)
let d = s.doc()?;
let grids = hs
.iter()
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
.count();
for _ in 0..grids {
s.echo("The object is minserted.");
}
explode_with(s, hs, explode_kind)
}
/// A block reference with more than one row or column (MINSERT).
fn minserted(i: &cadcraft_doc::Insert) -> bool {
i.rows > 1 || i.cols > 1
}
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
pub(crate) fn explode_with(
+25 -2
View File
@@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": nh.hex() }))
}
/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's
/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z).
fn keep_z(src: &EntityKind, k: &mut EntityKind) {
match (src, k) {
(EntityKind::Line(s), EntityKind::Line(l)) => {
l.a.z = s.a.z;
l.b.z = s.b.z;
}
(EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z,
(EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z,
(EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation,
(EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z,
(EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z,
_ => {}
}
}
/// Add the offset copy `k` of `src` (on the current layer instead of the source's when
/// `current_layer`), then erase the source when `erase` and its layer is not locked.
fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is
/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's
/// keeps it, as AutoCAD 2024 makes them.
fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
keep_z(&src.kind, &mut k);
let space = s.space();
let d = s.doc_mut()?;
let mut common = src.common.clone();
if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) {
common.thickness = 0.0;
}
if current_layer {
common.layer = d.header.str("CLAYER", "0");
}
+181
View File
@@ -0,0 +1,181 @@
//! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the
//! objects it makes a block of, or -WBLOCK the objects it writes, with their handles and each where
//! it was in the draw order, as AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read
//! back afterwards).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Space};
use serde_json::{Value, json};
use crate::{Result, Session};
/// One erased object, with the objects that were directly under and above it.
#[derive(Clone, Debug)]
struct Taken {
space: Space,
entity: Arc<Entity>,
below: Option<Handle>,
above: Option<Handle>,
}
/// What the drawing's last erase removed, for OOPS.
#[derive(Clone, Debug, Default)]
pub(crate) struct Erased(Vec<Taken>);
impl Erased {
/// Never bring back the objects `drop` picks.
pub(crate) fn forget(&mut self, drop: impl Fn(Handle) -> bool) {
self.0.retain(|t| !drop(t.entity.handle));
}
}
/// Remove the objects `hs` and keep them for OOPS in place of what an earlier erase kept.
/// Returns how many went; when none did, OOPS keeps what it had.
pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let d = s.doc_mut()?;
// Every neighbour is read before anything goes: objects erased together may be neighbours.
let at: Vec<(Handle, Space, Option<Handle>, Option<Handle>)> = hs
.iter()
.filter_map(|&h| {
let space = d.space_of(h)?;
let (below, above) = d.space(&space)?.neighbours(h)?;
Some((h, space, below, above))
})
.collect();
let mut taken = Vec::with_capacity(at.len());
for (h, space, below, above) in at {
if let Some(entity) = d.space_mut(&space).and_then(|st| st.remove(h)) {
taken.push(Taken { space, entity, below, above });
}
}
let n = taken.len();
// During REFEDIT, removing an object outside the working set gets the command undone
// (`refedit::after_command`), so what OOPS had stays.
let refused = taken.iter().any(|t| crate::cmd::blocks::refedit::locked(d, t.entity.handle));
if n > 0 && !refused {
s.state_mut()?.erased = Erased(taken);
}
Ok(n)
}
pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result<Value> {
let taken = s.state()?.erased.0.clone();
let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone());
let back: Vec<bool> = {
let d = s.doc()?;
let in_blocks = in_blocks(d, &taken, editing.as_deref());
taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect()
};
// Nothing to bring back leaves the drawing as it is: no undo step.
if !back.contains(&true) {
return Ok(json!({ "restored": 0 }));
}
let n = restore(s.doc_mut()?, &taken, &back);
Ok(json!({ "restored": n }))
}
/// The handles of `taken` that a block's object has now (an undo can take the drawing back to
/// before an erased object was made, and a block's new object take its handle). The block open
/// in the Block Editor (`editing`) doesn't count: its objects there keep the handles they have in
/// it.
fn in_blocks(d: &Drawing, taken: &[Taken], editing: Option<&str>) -> HashSet<Handle> {
let hs: HashSet<Handle> = taken.iter().map(|t| t.entity.handle).collect();
let mut out = HashSet::new();
for b in d.blocks.values() {
if editing.is_some_and(|n| b.name.eq_ignore_ascii_case(n)) {
continue;
}
// Through whichever is smaller.
if b.entities.len() < hs.len() {
out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h)));
} else {
out.extend(hs.iter().copied().filter(|h| b.entities.contains(*h)));
}
}
out
}
/// Whether `t` can come back: it is not in the drawing (OOPS again, or an undo brought it back),
/// its handle is one the drawing has given out, a reference being edited in place (REFEDIT)
/// doesn't keep it out, and its layout, layer and block are still there.
fn can_return(d: &Drawing, t: &Taken) -> bool {
let h = t.entity.handle;
h.0 < d.handseed
&& d.entity(h).is_none()
&& !crate::cmd::blocks::refedit::locked(d, h)
&& d.space(&t.space).is_some()
&& d.layer(&t.entity.common.layer).is_some()
&& match &t.entity.kind {
EntityKind::Insert(i) => d.block(&i.block).is_some(),
_ => true,
}
}
/// Put back the objects `back` marks. Each run of objects that were neighbours comes back from
/// its bottom one up, so each goes directly above the one that was under it.
fn restore(d: &mut Drawing, taken: &[Taken], back: &[bool]) -> usize {
let index: HashMap<Handle, usize> = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect();
let next: HashMap<Handle, usize> = taken.iter().enumerate().filter_map(|(i, t)| Some((t.below.filter(|b| index.contains_key(b))?, i))).collect();
let mut done = vec![false; taken.len()];
let mut n = 0;
for first in 0..taken.len() {
if taken[first].below.is_some_and(|b| index.contains_key(&b)) {
continue;
}
let mut at = Some(first);
while let Some(i) = at {
if std::mem::replace(&mut done[i], true) {
break;
}
if back[i] && put_back(d, taken, &index, i) {
n += 1;
}
at = next.get(&taken[i].entity.handle).copied();
}
}
n
}
/// Put `taken[i]` back directly above the nearest object that was under it and is here; when
/// those are gone, directly under the nearest one that was above it; else on top.
fn put_back(d: &mut Drawing, taken: &[Taken], index: &HashMap<Handle, usize>, i: usize) -> bool {
let t = &taken[i];
let Some(store) = d.space_mut(&t.space) else { return false };
let h = t.entity.handle;
store.push(Entity::clone(&t.entity));
match nearest(store, taken, index, t.below, |t| t.below) {
Some(Some(b)) => {
store.move_next_to(h, b, true);
}
Some(None) => {
store.send_to_back(h);
}
None => {
if let Some(Some(a)) = nearest(store, taken, index, t.above, |t| t.above) {
store.move_next_to(h, a, false);
}
}
}
true
}
/// From `h`, past objects erased with it that are not here, to the first object that is
/// (`Some(Some)`), the end of the draw order (`Some(None)`), or an object that is gone (`None`).
fn nearest(
store: &EntityStore,
taken: &[Taken],
index: &HashMap<Handle, usize>,
mut h: Option<Handle>,
step: fn(&Taken) -> Option<Handle>,
) -> Option<Option<Handle>> {
for _ in 0..=taken.len() {
let Some(x) = h else { return Some(None) };
if store.contains(x) {
return Some(Some(x));
}
h = step(taken.get(*index.get(&x)?)?);
}
None
}
+4 -4
View File
@@ -1,6 +1,6 @@
use super::break_join::join;
use super::explode::explode;
use super::transform::{erase, transform_entities};
use super::transform::{at_elevation, erase, scale_entities, transform_entities};
use super::{
Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number,
};
@@ -242,7 +242,7 @@ impl SelectThen {
let f = require_length(Some(f))?;
// The next SCALE offers this factor.
s.last_used.scale_factor = f;
transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?;
scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?;
s.set_selection(Vec::new());
Ok(Step::Done)
}
@@ -439,13 +439,13 @@ impl Interactive for SelectThen {
};
if f > 1e-12 && f.is_finite() {
s.last_used.scale_factor = f;
transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?;
scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?;
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Op::Mirror, 1, Input::Point(p)) => {
if p.near(self.pts[0], 1e-12) {
if p.near(self.pts[0], cadcraft_geom::EPS) {
return Err(EngineError::Other("Mirror line points must differ.".into()));
}
self.pts.push(p);
+38 -13
View File
@@ -1,8 +1,33 @@
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
use cadcraft_geom::Vec3;
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, erase_kept, f64_req, json, point_param, point_req, targets};
// ---------------- core operations ----------------
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
edit_entities(s, hs, copy, |e| e.kind.transform(m))
}
/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY,
/// and every height of the objects about its Z, their thickness too.
pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result<Vec<Handle>> {
let m = Mat3::scale_about(base.xy(), f);
edit_entities(s, hs, copy, |e| {
e.kind.transform(&m);
e.kind.scale_heights(base.z, f);
e.common.thickness *= f;
})
}
/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one.
pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 {
let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite());
Vec3::new(p.x, p.y, z.unwrap_or(0.0))
}
/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object
/// on a locked layer is left alone unless it is copied. The handles edited or made.
fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result<Vec<Handle>> {
let d = s.doc_mut()?;
let mut out = Vec::new();
for h in hs {
@@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
let nh = d.new_handle();
let mut ne = Entity { handle: nh, ..e };
ne.kind.transform(m);
edit(&mut ne);
if let Some(st) = d.space_mut(&sp) {
st.push(ne);
out.push(nh);
@@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
if locked {
continue;
}
d.modify_entity(*h, |e| e.kind.transform(m))?;
d.modify_entity(*h, &edit)?;
out.push(*h);
}
}
@@ -29,14 +54,9 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
}
pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);
if !locked && d.remove_entity(*h).is_some() {
n += 1;
}
}
let d = s.doc()?;
let free: Vec<Handle> = hs.iter().copied().filter(|h| !d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)).collect();
let n = erase_kept(s, &free)?;
s.set_selection(Vec::new());
Ok(n)
}
@@ -92,7 +112,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
if f <= 0.0 {
return Err(bad("scale", "factor must be positive"));
}
let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?;
// A Z given with the base point, as AutoCAD takes one; without one, the current elevation.
let base = match p.get("base").and_then(|b| b.get(2)) {
Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?,
None => at_elevation(s, base),
};
let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
@@ -100,7 +125,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let a = point_req("mirror", p, "p1")?;
let b = point_req("mirror", p, "p2")?;
if a.near(b, 1e-12) {
if a.near(b, cadcraft_geom::EPS) {
return Err(bad("mirror", "mirror line points coincide"));
}
let erase_src = bool_or(p, "erase", false);
+49 -11
View File
@@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
}
}
/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep`
/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the
/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle.
fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> {
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true };
let a_end = ga.center.angle_to(end);
let ccw = sweep > 0.0;
// How far past `end` each crossing is, turning the way the segment turns; crossings on the
// segment itself (the last |sweep| of a turn) are not ahead of it.
let (x, more) = cut
.iter()
.flat_map(|c| intersect_ext(&full, c, false))
.map(|x| {
let a = ga.center.angle_to(x);
(x, norm_angle(if ccw { a - a_end } else { a_end - a }))
})
.filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9)
.min_by(|a, b| a.1.total_cmp(&b.1))?;
let total = sweep.abs() + more;
Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total })))
}
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
if crate::cmd::curves::is_locked(s, h) {
@@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
// The end segment's bulge, signed for travel from `prev` out to `end`.
let i_seg = if from_end { i_prev } else { i_end };
let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge });
let none = || EngineError::Other("Object does not intersect an edge.".into());
if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) {
// An arc end segment runs on round its circle, as an arc does.
let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
if let Some(v) = vs.get_mut(i_seg) {
v.bulge = if from_end { b } else { -b };
}
} else {
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
}
lwpoly(vs, false)
// Width, elevation and linetype generation stay as they were.
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
}
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
};

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