Compare commits

...
Author SHA1 Message Date
skylartaylor c569cd3514 M0: clarify screenshot cancellation after issue 2026-10-25 08:10:37 +01:00
skylartaylor 5631cdb5fb Reconcile current CADCraft main with the egui 0.37 migration 2026-10-25 07:25:31 +01:00
aiongg e86d895ba1 Merge pull request #2115 from aiongg/dotcoocoo-followups
Batch follow-ups: #2049's later commits, CUT on locked layers, BLOCKREPLACE, THICKNESS and Volume prompts
2026-10-25 01:17:57 -04:00
aiongg c6c3aefe3f COMPAREEXPORT: a dimension whose block can't be copied is drawn again in the comparison color; Volume's second point says it needs a point; comments say only dimensions leave anonymous block names out 2026-10-25 05:06:46 +00:00
aiongg b34477d580 cargo fmt 2026-10-25 04:41:25 +00:00
aiongg 3d7e298b7d gaps.md: F7 notes the DIMTFILL 1 and true-color DIMTFILLCLR limits of written files 2026-10-25 04:26:38 +00:00
aiongg 23d44cd9c8 MEASUREGEOM Volume: the height can be given by two points, and Enter at the height prompt says a height is required 2026-10-25 04:26:14 +00:00
aiongg 8296bbc642 THICKNESS: a typed value out of range is reported as such and the prompt asks again 2026-10-25 04:22:57 +00:00
aiongg b90d68ec34 BLOCKREPLACE: a dynamic block or a replacement that references the block is refused when its name is given, and the prompt asks again 2026-10-25 04:17:52 +00:00
aiongg c42763ba0f CUTCLIP and CUTBASE leave the clipboard alone when every object is on a locked layer, and say how many were locked 2026-10-25 04:12:57 +00:00
aiongg 642c3a59a3 COMPAREEXPORT: free block and style names come from a next-index counter per name base instead of a scan from 1 2026-10-25 04:04:14 +00:00
aiongg 53049d82e0 COMPAREEXPORT: keep #2049's *D copies of dimension blocks 2026-10-25 03:58:15 +00:00
aiongg e23a362c54 Merge storytold/cadcraft#2049 (commits after the batch) 2026-10-25 03:57:25 +00:00
aiongg f46a220375 Merge pull request #2112 from aiongg/dotcoocoo-batch
Merge 11 open PRs (tested batch)
2026-10-24 23:56:07 -04:00
aiongg 8171c9be17 DIMTFILL and DIMTFILLCLR are written in DIMSTYLE only when set; COMPAREEXPORT redraws dimensions instead of copying their blocks, and gaps.md notes its groups 2026-10-25 03:43:34 +00:00
dotCooCoo 4dbcbfdaaa COMPAREEXPORT: dimension block copies stay *D blocks, and image paths count in block reuse
A dimension's block took a recolored copy named COMPARE_<tag>_D1, and
the DXF writer redraws a dimension whose block is not a *D block, so
saving the snapshot replaced geometry read from a file. The copy now
gets a free *D name.

A named block both drawings define was reused when its images and
data links had the same relative paths, even when they were relative
to different folders. These paths are now taken from each drawing's
folder before the blocks are compared.
2026-10-24 19:37:44 -07:00
aiongg 429554b389 COMPAREEXPORT: tests move into the engine's it suite (the hook test joins tests/compare.rs); the copy drops constraint display exceptions for objects it doesn't hold 2026-10-25 02:35:48 +00:00
aiongg 17952af3fb Regenerate the parity checklist for the batch 2026-10-25 02:33:36 +00:00
skylartaylor a58bdedb69 egui037: retain CADCraft shared docking on current main 2026-10-25 03:24:02 +01:00
aiongg 1b0cefce9c Merge storytold/cadcraft#2049
# Conflicts:
#	docs/gaps.md
2026-10-25 02:23:03 +00:00
aiongg e1cab10d01 Merge storytold/cadcraft#2089
# Conflicts:
#	crates/engine/src/cmd/utility.rs
2026-10-25 02:22:50 +00:00
aiongg 05cad7db49 Merge storytold/cadcraft#2085
# Conflicts:
#	crates/engine/src/cmd/utility.rs
#	crates/engine/tests/it/inquiry_keywords.rs
2026-10-25 02:22:40 +00:00
aiongg 4e73fb5bb3 Merge storytold/cadcraft#2079
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 84e3478863 Merge storytold/cadcraft#2071
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg fac2d18df6 Merge storytold/cadcraft#2065
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 23a0bd4fdb Merge storytold/cadcraft#2054
# Conflicts:
#	docs/parity-checklist.md
2026-10-25 02:22:16 +00:00
aiongg 75c54ce35d Merge storytold/cadcraft#2056 2026-10-25 02:21:58 +00:00
aiongg 313fab8827 Merge storytold/cadcraft#2101 2026-10-25 02:21:58 +00:00
aiongg bda4a9924c Merge storytold/cadcraft#2106 2026-10-25 02:21:58 +00:00
aiongg 3e64cc5d06 Merge storytold/cadcraft#2105 2026-10-25 02:21:58 +00:00
dotCooCoo 20087b4b5f COMPAREEXPORT: multiline attributes take the comparison color too
A multiline attribute is drawn from its own multiline text, so an
inline color code in it overrode the comparison color set on the
attribute: an unchanged attribute could stay red, or a removed one
green. The export now takes the inline colors out of the multiline
text of a reference's attributes and of attribute definitions in the
recolored blocks, as it does for MTEXT and multileader text.
2026-10-24 19:16:04 -07:00
dotCooCoo 5685085add COMPAREEXPORT: a block whose objects are hidden or thicker in the other drawing is copied as PART_2
The comparison of a named block both drawings define counted each
object's geometry, layer, color, linetype, lineweight and linetype
scale. A block whose line was hidden, more transparent, thicker or
extruded another way in the other drawing counted as the same, so the
other drawing's references drew with this drawing's definition. These
properties now count as well.
2026-10-24 18:52:56 -07:00
dotCooCoo 32be8a655e COMPAREEXPORT: a dimension block with another generated name does not make PART differ
A named block such as PART was compared with the other drawing's by the
blocks it draws, including a dimension's block. That block's generated
name (`*D1` here, `*D3` there) was part of its signature, so the same
PART counted as different and was copied as PART_2 with a copy of the
dimension block. The comparison now leaves out an anonymous block's
name and compares its contents.
2026-10-24 18:43:16 -07:00
aiongg b901656669 Panels: use craft-ui's shared docking (#2093)
* Pin craft-libs at 420d21a

* Pin craft-libs at 16fb3d4

* Use craft-ui's shared docking workspace and ui.dock commands

Replace the local Workspace, its validation, apply, visibility and the
ui.dock parser with craft_ui::docking::Workspace and DockOperation.
CADCraft keeps its panels, the drawing view's rules, the default layout
and the workspace file. Add tests for loading a v1 workspace file and for
dragging a panel group by its bar.

* Pin craft-libs at 6e7f285

* Docking: reveal panels through craft-ui's set_visible, which reopens groups and places new panels itself

* Docking: palette switches regroup a panel whose recorded anchor is hidden instead of opening it alone
2026-10-24 20:23:39 -04:00
skylartaylor 46ad6cd7af M-EGUI037: port CADCraft and reconcile current main 2026-10-25 02:10:34 +02:00
dotCooCoo 1271b06b02 Refuse negative values for whole-number DIM* variables
DimStyle::apply_fields rounded a value for a whole-number field such as
DIMDEC or DIMTMOVE and clamped it to 0..255, so DIMDEC -1 became 0 and
DIMALTD "300" became 255. DIMSTYLE, DIMOVERRIDE and the SETVAR overrides
that new dimensions take all accepted these values.

A value below 0 or one that rounds above 255 is now refused, as an
out-of-range value is. DIMADEC still takes -1. A lineweight given as
-1, -2 or -3 (ByLayer, ByBlock, Default) is no longer read as 0 when
the style's lineweight is positive, and a lineweight above 211 given as
text is refused instead of read as 211.
2026-10-24 16:34:50 -07:00
dotCooCoo 6dc336160d SETVAR refuses DIM* values the dimension style refuses
SETVAR wrote a DIM* variable to the drawing header without checking it,
so values such as DIMTMOVE 3, DIMATFIT 7 or DIMLWD 500 were accepted.
New dimensions then ignored them, because the style override goes
through DimStyle::apply_fields, which refuses them.

SETVAR now runs the value through apply_fields on a copy of the current
dimension style, as DIMSTYLE and DIMOVERRIDE do, and reports an invalid
value instead of writing it.
2026-10-24 15:22:26 -07:00
dotCooCoo 8a64e42100 DIMTFILL: dimension text gets a background in the drawing's background color or in DIMTFILLCLR
DIMTFILL and DIMTFILLCLR were not modeled, so a dimension's text never had a
background and lines under it stayed visible. Dimension styles now carry both
variables. DIMTFILL 1 hides what is drawn under the text in the drawing's
background color, and DIMTFILL 2 fills the box in DIMTFILLCLR. The box is the
text box widened by DIMGAP on every side. The arc length symbol over the text
and the DIMINSPECT frame are drawn over the background.

The variables work with SETVAR, DIMOVERRIDE and the style fields. DXF stores
them as DIMSTYLE groups 69 and 70 from R2007 and in the style's CADCRAFT
xdata in older files; the reader skips the record's flags, which also use 70.
For DIMTFILL 2, the dimension's *D block and EXPLODE add a SOLID in
DIMTFILLCLR under the text. A dimension drawn from the *D block it was read
with gets a background around each of the block's texts, before the block's
first text or first segment of the text's marks, except for a text that a
SOLID of the block already covers. The background stays after the drawing is
saved and reopened.
2026-10-24 14:11:01 -07:00
aiongg fcfdf33001 Merge pull request #1556 from silaghis/fix/stretch-ortho-window
Keep rectangle opposite corners free of ortho (STRETCH window and friends)
2026-10-24 16:15:54 -04:00
dotCooCoo 898b539630 COMPAREEXPORT: a block left out at the block limit is reported when this drawing's differs
Past the limit on blocks copied, a block of the other drawing that this
drawing also defines was skipped without a note, and the red group drew
this drawing's definition even when the two differed. Such a block now
counts as left out unless both drawings' blocks draw the same thing, so
the export says that some blocks were too many to copy.
2026-10-24 11:50:33 -07:00
dotCooCoo bfa30e2192 MEASUREGEOM: Tools > Inquiry > Radius asks for the object, and Radius measures polyline arcs
Tools > Inquiry > Radius had no prompts, so with nothing selected it
stopped with "`handle` is required". It now asks "Select arc or circle"
and reports the radius and diameter of the arc or circle picked. A
selected arc or circle is still measured at once.

MEASUREGEOM's Radius option and Tools > Inquiry > Radius also measure
the arc segment of a polyline under the pick. radius.measure takes an
optional `point`, and for a polyline it measures the arc segment nearest
that point.
2026-10-24 11:34:08 -07:00
dotCooCoo 432cbfb106 COMPAREEXPORT: a named block whose dimension draws another block is copied as PART_2
A named block that holds a dimension was kept as this drawing's
definition when only the block the dimension draws differed between the
drawings, so the red group drew this drawing's dimension graphics. The
export's block comparison now covers a dimension's block, as it covers a
multileader's.
2026-10-24 11:24:50 -07:00
dotCooCoo 596ea36465 MEASUREGEOM: malformed points and volumes past f64's range are errors
AREA and Volume measured the selected object when `points` was given but
was not a list of points; they now report an error, and only an absent
or null `points` measures the selection. A volume too large for f64
(an area times a height) is an error in the JSON form. At the typed
prompt, a volume or an Add/Subtract volume running total too large for
f64 makes the prompt say "The volume is too large." and ask for the
height again, where it reported "Volume = 0" or "Total volume = 0".
2026-10-24 11:11:51 -07:00
dotCooCoo dc081c6f7f MEASUREGEOM: add an Angle option, and Tools > Inquiry > Angle asks for the objects
MEASUREGEOM typed at the command line offered Distance, Radius and ARea,
and Tools > Inquiry > Angle ran without a prompt, so it failed asking for
{vertex,p1,p2} or {h1,h2}. MEASUREGEOM now has an Angle option, and it
and Tools > Inquiry > Angle ask "Select arc, circle, line <specify
vertex>". An arc reports its included angle. A circle takes the pick as
the first endpoint, uses the center as the vertex, and asks for the
second endpoint. Two lines or polyline line segments report the angle
where the lines meet, extended. Enter asks for a vertex and two
endpoints. The JSON form of angle.measure is unchanged.
2026-10-24 09:30:38 -07:00
dotCooCoo 746942613c CUTBASE: a base point is required, and a handle listed twice is cut once
cutbase and copybase now fail with "`base` ([x, y]) is required" when
`base` is missing or not a point, as their parameters say, instead of
taking the lower-left corner of the objects. cutbase checks it before
erasing anything.

Copy, Copy with Base Point, Cut and Cut with Base Point put an object on
the clipboard once however often `handles` lists it, so the counts they
return match the objects copied or cut.
2026-10-24 09:00:24 -07:00
dotCooCoo 1b8be0b77d COMPAREEXPORT: a named block the other drawing defines differently is copied as PART_2
A block reference of the other drawing whose named block exists in this
drawing with other contents drew this drawing's definition in the red
group. The export now keeps this drawing's block only when both
drawings' blocks draw the same thing: the same objects, base point,
units, annotative and overlay flags and xref file, in the block and in
the blocks its references and multileaders use. Otherwise the other
drawing's block is copied under a free name (PART_2, PART_3, ...) and
its references point at the copy.
2026-10-24 08:26:46 -07:00
dotCooCoo 7fe832b284 MEASUREGEOM: a Volume option, and Tools > Inquiry > Volume
MEASUREGEOM offered Distance, Radius and ARea, and Tools > Inquiry had
no Volume item. The Volume option measures a polygon of three or more
points, or a closed 2D object as AREA measures it, and asks for a
height. It reports the area times the absolute value of the height.
Add volume and Subtract volume keep a running total, as AREA's Add
area and Subtract area do. Tools > Inquiry > Volume (volume.measure)
starts the same prompts, and the JSON form takes points or a handle
and a height.

Engineering and architectural units show the volume as a decimal
number. The de, fr and ru catalogs get a "Volume" row, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 08:09:26 -07:00
dotCooCoo 1fcdfc87b4 ATTDISP: View > Attribute Display > Normal, On and Off, and 'ATTDISP inside another command
ATTDISP had no menu entry. View > Attribute Display now has Normal, On
and Off, which set ATTMODE to 1, 2 and 0 as ATTDISP's options do. ATTDISP
can also be typed as 'ATTDISP while another command runs, and that
command resumes afterward.

The eight catalogs without a "Normal" row get one, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 07:04:43 -07:00
dotCooCoo 5a82f090df BLOCKREPLACE: Tools > Express Tools > Replace Block replaces every reference of one block with another
BLOCKREPLACE (and -BLOCKREPLACE) turns every reference of one block, in
model space, the layouts and other blocks, into a reference of another
block at the same insertion point, scale and rotation. Each new
reference carries the replacement's attributes, with the values of the
tags both blocks define, and is annotative when the replacement is.
The command then asks whether to purge the replaced definition, and
removes it only when no dimension style names it and no block
reference, dimension or multileader in the drawing or in another block
definition does.

The command refuses a replacement that references the block it
replaces, a dynamic block as the block to replace, and blocks that
come from an xref. It does not run while the Block Editor or REFEDIT
is open. The ATTSYNC helpers in blocks/attribs.rs walk the references
and build the new attributes.

The self-reference check that BLOCK, INSERT of a file, REFCLOSE and
Lisp block creation share now also follows dimension blocks and
multileader block content, so BLOCK no longer accepts a redefinition
of A from a multileader whose content is A.

docs/parity-checklist.md is regenerated.
2026-10-24 05:57:38 -07:00
aiongg 41d9b5e957 PDF: plot with craft-pdf's krilla writer (#2029)
* Depend on craft-pdf (krilla writer, reader for tests) at craft-libs 9f75d3dc

The root patches pin the patched krilla and hayro-syntax/hayro-jbig2 to
the same revision, as craft-pdf requires.

* Plot PDFs with craft-pdf's krilla writer

The hand-written object, xref, stream and font writer goes. Each sheet's
display list is built, written as its page and dropped before the next.
Strokes with the same colour, width and transparency stay one path, and
each filled primitive is one path of its triangles. Text in embeddable
TrueType fonts is drawn as glyphs; krilla subsets the fonts and writes
their ToUnicode maps, so the hand-written subsetter goes too and
cadcraft-fonts keeps only the fsType embedding check. Primitives with a
point that is not finite or beyond 1e7 pt are left out. A font krilla
cannot embed after all makes the export retry with text as outlines.

Optional content (PDF layers) is not written until craft-libs' writer
supports it: the layer transitions go through one empty function, layers
that are off are not plotted, and the layer tests are ignored.

The tests read the PDF back with craft-pdf's reader.

* Pin craft-pdf and its krilla and hayro patches to craft-libs cd160e73

cd160e73 adds optional content groups to the patched krilla.

* Write drawing layers as PDF optional content groups

Each drawing layer a page draws on gets one group, named after the layer
and shared by all pages, created before the first page that uses it. A
group starts hidden when its layer is off in the drawing, so `offLayers`
again plots layers that are off on hidden PDF layers. At most 4096
groups per file; objects on further layers print unmarked. A single
sheet with groups opens with the viewer's layer panel (/PageMode /UseOC).
Layer changes end and start the marked content, and the last mark ends
before the page finishes.

The layer and text tests that check the groups run again, and a test
covers the group cap.

* Plot a zero-length line as a round dot in PDFs

krilla leaves out a path without extent, so a lone zero-length line drew
nothing. It is filled as the dot its round-capped stroke would make, as
wide as the stroke, with 0.25 pt for a hairline.

* PDF: retry as outlines when the writer fails inside a font; cap layer names; restore two test checks

* Tests: read plotted PDFs back through the shared reader in plot_layout_scale and plot_styles

* Pin craft-pdf, krilla, hayro-syntax and hayro-jbig2 to craft-libs main (7895f5e)

* PDF tests: read path coordinates in page space; cover the font retry and out-of-range points

- pdf_read maps path operands through the CTM (`page_ops`, used by `drawn` and
  `clip_rect`) and checks that the plot area is drawn in page space.
- The 1:1 layout test finds the viewport corner at (72, 72) in page space, so a
  mirrored page fails it.
- A font with a short hmtx passes the writer's font checks but fails subsetting;
  the plot retries and draws its text as outlines.
- A primitive with a point past 1e7 pt is left out, and the rest still plots.

* PDF: clip a primitive with a far vertex to the plot area instead of leaving it out

A polyline or fill with a point past the writer's f32 range now keeps the
parts inside the plot area (polylines with the area widened by the stroke
width), each piece its own subpath. Primitives in range are written as before.
The test reader requires a decodable content stream.
2026-10-24 08:38:46 -04:00
dotCooCoo 6fe715e11c THICKNESS: Format > Thickness, and new 2D objects take the current thickness
New lines, arcs, circles, points and lightweight polylines (for example
PLINE, POLYGON, DONUT and BOUNDARY) now take the THICKNESS system
variable as their 3D thickness. THICKNESS could already be set with
SETVAR and with the Properties palette when nothing is selected, but new
objects were always created with a thickness of 0, apart from RECTANG's
own Thickness option. RECTANG keeps that option, and single-line text
does not take THICKNESS, as in AutoCAD.

The new THICKNESS command is on Format > Thickness. Typed or picked from
the menu, it asks "Enter new value for THICKNESS <0.0000>:"; Enter keeps
the current value, and a negative value is accepted.
docs/parity-checklist.md is regenerated.
2026-10-24 03:24:13 -07:00
dotCooCoo 028b98d71c CUTBASE: cut objects to the clipboard with a base point
Edit > Cut with Base Point asks for a base point, then for the objects,
copies them to the clipboard with that base point and erases them, as
COPYBASE does without the erase. A pickfirst selection skips the object
prompt. Objects on locked layers stay, as with CUTCLIP, which now shares
the cut helper. The JSON form takes {handles?, base: [x,y]}.

Catalog rows for the new menu label are added in all 15 languages, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 02:29:26 -07:00
skylartaylor 2cf5c8ad66 egui037: reconcile CADCraft with current main and retain migration fixes 2026-10-24 11:20:05 +02:00
Dylan M. TaylorandClaude Sonnet 5.5 b9f96d3dd3 refactor: replace wildcard imports in two test modules (S2208) (#2036)
* refactor: replace wildcard imports in two test modules (S2208)

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

* style: fmt

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:49:51 -04:00
dotCooCoo 39e9b62829 Contributors: add dotCooCoo (Robert Lee) 2026-10-24 01:36:29 -07:00
dotCooCoo 4aed7a2267 COMPAREEXPORT: export a drawing comparison to a new drawing
COMPAREEXPORT compares the two drawings again and opens the result as a
new untitled drawing, compare_<this> vs <other>.dwg. Model space holds
the other drawing's removed and changed objects as group
COMPARE_COMPARED and the compared tab's objects as group
COMPARE_CURRENT, in the overlay's colors: green for objects only in
this drawing or changed in it, red for the other drawing's, gray for
objects in both.

Block references, dimensions and multileaders refer to recolored copies
of their blocks, tables and tolerances use recolored copies of their
styles, and inline MTEXT and cell colors, dimension style colors,
multiline element colors and gradients follow the object. The new
drawing takes the layers and styles of the other drawing that this
drawing lacks, and the blocks its exported objects refer to; an
anonymous block whose name is taken is copied under a free name. The
comparison bar gets an Export button.
2026-10-24 01:36:29 -07:00
63394e872d chore: resolve SonarCloud maintainability issues (#2025)
* chore: resolve SonarCloud maintainability issues

* style: format with rustfmt

* fix(engine): reference DocState instead of DrawingState

* fix: restore cmd wildcard imports and remove unused Arc import

* Unit tests for the extracted mleader alignment steps and spacemouse filter

* test: fix assertions flagged in review and apply rustfmt

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

* test: initialise drift filter in struct literal for clippy

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

---------

Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 -04:00
Nicholas GowerandBrandon Thomas e59195edb8 usage: a read-only cross-reference of layers, blocks, styles and linetypes (#1889)
* usage: a read-only cross-reference of layers, blocks, styles and linetypes

USAGE reports, for every layer, block, text style and linetype, how many
objects use it in model space and the layouts, how many inside block
definitions, whether it is used only inside block definitions, and whether
it is empty or unused. Its unused lists are PURGE's own scan (candidates and
block reachability), shared read-only; PURGE's behaviour is unchanged.

The JSON form takes {type?, limit?, offset?}, returns schema 1 with a paged
items list, per-kind summary and PURGE's unused names in table order.

Fixes #1439

* docs: document the functions touched by #1439

* usage: mark a scan cut at its limit as incomplete (#1439)

USAGE's one pass over model space, the layouts and the block definitions was
capped at 5,000,000 objects with `take`, so a larger drawing silently omitted
later references while the counts, statuses, summary and message looked
complete — an unused layer or block could be reported as used, or vice versa.

The limit is now `MAX_SCAN`, and `Scan::new` takes it as a parameter: it counts
the drawing's objects first and records an `Incomplete { scanned, total }` when
the limit cuts the scan short. The report carries `incomplete: true` and an
`incompleteReason` naming both counts, and the message ends with a note, so a
partial report can never be mistaken for a complete one. A drawing holding
exactly the limit is still complete.

PURGE's scan limit and candidate behaviour are untouched; USAGE only reads
PURGE's unused lists as before.

Tests: unit tests cut a small drawing at a small limit (a 5,000,000-object
drawing is impractical) and check the boundary and the miscount the signal
prevents; the integration test checks a complete drawing says so. Also adds the
missing Ukrainian translation for the command label, which the i18n gate
requires.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 02:04:10 -04:00
Brandon ThomasandClaude Opus 5.5 5e39af933f test(autosave): fix a flake: the owner-only folder test uses its own instance id
Two tests that start in the same millisecond got the same instance_id(), so this test's
shutdown() removed the other test's lock from the process-wide SAVEFILEPATH (after #1868).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:02:53 -04:00
c66f6f45b8 Reject bare or signed second token in mixed distances (#2011)
Found while testing crates/engine/src/units.rs:176-179 (parse_mixed)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:50 -04:00
b062030a03 Reject a leading '+' in ACI indices and RGB triples in Color::parse (#1878)
Found while testing crates/color/src/lib.rs:115 and 126

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:45 -04:00
Nicholas GowerandBrandon Thomas 7aa952b8da DWG: write AutoCAD's drawing variable dictionary (AC1018 output) (#1872)
* DWG: write AutoCAD's drawing variable dictionary (fixes #1813)

A 2004 (AC1018) DWG of a drawing without an AcDbVariableDictionary is read as
empty by Vectric Aspire 10.5, though the same drawing as DXF imports. AutoCAD
always writes the dictionary; CADCraft's and ezdxf's DXF writers don't.

The DWG bridge now adds it, with AutoCAD's usual variables, when the drawing
has none, for DWG versions before 2018 (AC1032 output reads fine without it).
A dictionary the drawing already has keeps its entries and values.

* docs: document the functions touched by #1813

* DWG: keep the drawing's DIMASSOC and HIDETEXT in the variable dictionary

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:59:40 -04:00
Nicholas GowerandBrandon Thomas 5cfe1a4149 appload: load a .lsp file from a path or the file picker (#1903)
* appload: load a .lsp file from a path or the file picker (#1820)

APPLOAD had no user interface: typed or chosen from Tools > Load Application it
started a command-line prompt instead of a file browser, and the JSON form did
not say which commands the file defined.

- The desktop's APPLOAD opens an rfd file browser filtered to .lsp, starting in
  the folder the last routine came from (kept in the preferences as lispDir).
  A cancelled picker is a no-op; the web (no native dialog) keeps the engine's
  name prompt.
- The JSON form `appload {path}` loads to the end, never opens a dialog and now
  returns `commands`, the `c:` commands the file defined; a non-.lsp path is
  refused and unreadable or unparsable files return an ordinary error.
- Tests: a synthetic .lsp in a temp folder loads by path and its command runs;
  the picker path, its cancelled answer and the remembered folder; refused,
  missing and broken files.

* appload: load a picked file interactively; keep path whitespace; web hint

Reviewer follow-ups on #1820:

- A picked file now loads through the interactive APPLOAD command (started
  and answered with the picked path, as a typed name is), so a routine it
  starts that calls getpoint or another get… waits for the user instead of
  receiving nil. JSON calls keep the blocking run.
- A JSON `path` is read as given: only a path of blanks is refused, so a
  relative name with leading or trailing spaces is not trimmed into another
  file.
- The web has no file system, so APPLOAD no longer asks for a file name it
  cannot read: it says to drop a .lsp file on the window, which loads the
  file's contents. The host declares this through Services::no_file_system.

Tests: a picked file whose load asks for a point waits for it; a relative
path in a folder whose name starts with a space loads; on a host without a
file system APPLOAD gives the drop hint instead of prompting.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:57:06 -04:00
Nicholas GowerandBrandon Thomas 9f2060d674 analysis: report found/analysed/skipped counts, caps and implausibility (#1890)
* analysis: report found/analyzed/skipped, caps and implausible values (fixes #1440)

* docs: document the functions touched by #1440

Add doc comments to the self-check helpers, the COUNT/SHAPES/MASSPROP entry points and helpers, and the tests and fixtures the analysis self-check change touches, so CodeRabbit's docstring coverage check passes.

* analysis: tolerate rounding past a bound, and skip area checks without extents (#1440)

The self-check's implausible() compared value > bound exactly, so a border
rectangle whose shoelace area equals the extents' width * height could be
flagged by one ulp of rounding. Allow a 1e-9 relative slack past the bound
(the roundings of two ways of measuring the same thing), while a clearly
larger value is still flagged.

MASSPROP and SHAPES fell back to an extents area of 0 when nothing visible
gave the drawing extents, so every region or shape with area got a false
"area is larger than the drawing extents" flag. Pass f64::INFINITY instead:
implausible() already skips non-finite bounds, because no bound applies.

Tests: one ulp past the bound is not flagged while 1e-6 past is; an
extents-filling rectangle is not flagged; invisible-only masses/shapes with
no visible extents are not flagged.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:56:51 -04:00
9fc626a33b MCP: the open tool description names DWG as well as DXF (#1887)
* MCP: the open tool description names DWG as well as DXF

The open tool reads both DXF and DWG through the io layer, but
tools/list advertised it as "Open a DXF file." so clients reading
the descriptions would not try a DWG. Say "Open a DXF or DWG file."

Adds a regression test that finds open in the tools/list reply and
asserts its description still mentions DXF and now mentions DWG.

Fixes #1875

* contributors: credit nicholasgower by GitHub profile name

* docs: document the functions touched by #1875

---------

Co-authored-by: nicholasgower <nicholasgower@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:56:34 -04:00
fef32761af DXF/DWG: angular, diametric and radial dimensions get DIMASSOC (#1651) (#1874)
* DXF/DWG: angular, diametric and radial dimensions get DIMASSOC (#1651)

std_assoc_refs had slots only for linear/aligned and ordinate dimensions, so
every other associative kind was saved without a DIMASSOC object: AutoCAD
did not follow angular, diameter or radius dimensions, although CADCraft
itself did through its xdata.

Write the references AutoCAD writes for each kind: both lines of an angular
dimension (4 point references; the 3-point kind its three definition points)
and, for radial and diametric dimensions, the single centre reference to the
circle or arc (associativity flag 2), which is how AutoCAD files store them.
Read the same references back, with the circle's definition points
reconstructed, so files from other writers associate those kinds too.

Curve start references now use osnap 13 as AutoCAD writes them (1 is the
end), which also lets a stored point of 0,0,0 resolve to the right end.

Tests: the issue's commands through the engine, and a DXF/DWG round trip
with CADCraft's xdata stripped so only the standard objects remain.

* docs: document the functions touched by #1651

* DXF read: a line's osnap 1 with a stored point still picks the nearer end (other writers)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 9562d951df contributors: repair people.toml entries for ffishman, aussieboer61 and nicholasgower (merge of #1735/#1792/#1863)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:55:51 -04:00
f05fa9cec3 DWG read: keep a polyline's vertex order when handles are not ascending (#1863)
* DWG read: keep a polyline's vertex order when handles are not ascending (fixes #1856)

acadrust 0.6.4 assembled a polyline's VERTEX records in handle order and discarded the polyline's owned vertex-handle list, so a vertex inserted into an existing polyline (and therefore given a higher handle) moved to the end of the outline. 0.6.5 reads the owned list and orders each polyline's vertices by it.

Adds a regression test that writes a DWG whose second 3D-polyline vertex has the highest handle, reads it through the DWG bridge, and checks the order for AC1018 and AC1032.

* contributors: credit @nicholasgower by GitHub profile name

* docs: document the functions touched by #1856

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:55:23 -04:00
73e7486dc9 DXF: proxy objects and entities keep their class through a save (#1812) (#1858)
* dxf: proxy objects and entities keep their class through a save (#1812)

A proxy (ACAD_PROXY_OBJECT, ACAD_PROXY_ENTITY) names its class by number
in group 91: 500 plus the class's place in the CLASSES section (DXF
Reference). Saving rewrites CLASSES (CADCraft's own classes, then the kept
ones), but kept proxies were written with their read number, which then
named another class: an R14 file's visual styles came back as SCALE,
TABLESTYLE or MLEADERSTYLE, and numbers past the last class stayed.

The reader now keeps the file's class order (Foreign::class_order) when
the file has proxies, and the CLASS records its proxies name. The writer
renumbers each kept proxy to its class's place in the written CLASSES and
drops a proxy whose number names no class (with what it owns and its
dictionary entry). The DWG bridge writes through the same path.

Test proxies_keep_their_class_when_classes_are_rewritten (a file with
VISUALSTYLE and WIDGETX first in CLASSES, proxies of both and of a number
past the last class, and a proxy entity; also through a second save).

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

* docs: the proxy class fix is for DXF save (#1812)

From the local CodeRabbit review: DWG save drops proxy entities (acadrust)
and proxy objects through DWG weren't checked, so the progress log says DXF.

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

* dxf: a proxy's class number is found outside 102 groups (#1812)

From the PR review: an application's 102 {NAME ... 102 } group before a
proxy's own groups can hold a group 91, which was read as the class number
and overwritten on save. class_group finds the first 91 outside 102 groups
(before xdata), for reading and renumbering alike (test
proxies_keep_their_class_when_classes_are_rewritten, now with a {MYAPP
group holding a 91 that stays as it was).

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

---------

Co-authored-by: Tyrel Haveman <1434399+tyrel@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:55:07 -04:00
fde62a39ab MEASUREINIT: choose the units of new drawings, kept in the profile (#1792)
* MEASUREINIT: choose the units of new drawings, kept in the profile

NEW, QNEW and the first drawing at startup were always imperial unless a
template or {"metric": true} was given, and nothing kept a metric choice
across restarts. MEASUREINIT (0 imperial, the default; 1 metric) is now a
profile variable: NEW without `metric` or a template follows it, the app's
startup drawing follows it, and Options > Files shows it as "Units of new
drawings". QNEWTEMPLATE and an explicit `metric` still win.

Part of #1182.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dxn7PD9WzP4ET2LU7BRLDK

* contributors: add Richard Sauer (aussieboer61)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dxn7PD9WzP4ET2LU7BRLDK

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:51 -04:00
7db656fdc7 Write a layout's paper-space viewport (id 1) ahead of its other viewports (#1782)
DWG stores no viewport ids; readers number a layout's viewports in order. A drawing whose draw
order (SORTENTS) put the paper-space viewport behind a model viewport came back from DWG with
that model viewport as the sheet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:36 -04:00
MarekSpodBiedryandBrandon Thomas 9d9e4c3b0d STRETCH moves only the multiline vertices inside the area (#1712) (#1739)
* STRETCH moves only the multiline vertices inside the area (#1712)

* STRETCH selects a multiline when a crossing area holds one of its vertices (#1712)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:54:20 -04:00
c92bb5cdad Close hatch boundaries where a line ends just short of a flattened ellipse (#1690) (#1738)
* Close hatch boundaries where a line ends just short of a flattened ellipse (#1690)

* Bound the hatch endpoint snap scan by segment count and visited segments (#1690)

* test: LINE returns its new objects as `handles`

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:54:04 -04:00
1c35d7f8c1 Keep the active layout when saving DWG/DXF (#1735)
* Keep the active layout when saving DWG/DXF

The layout a file marks active (block *Paper_Space) was not kept on read, and on write *Paper_Space always went to the first tab, so every save made the first sheet active. Store it as Drawing::active_layout and write it as *Paper_Space again; tab order is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Match the active layout by name without regard to case

paper_space_layouts and layout.rename now compare the active layout's
name case-insensitively, as Drawing::layout() does, so an active layout
stored as "s2" still resolves the "S2" tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test case-insensitive active layout matching

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:49 -04:00
BooksandBrandon Thomas a94fc0898b Diameter dimensions: the *D block's text keeps the sign as %%c (fixes #1645) (#1696)
The *D block and EXPLODE's MTEXT took the dimension's decoded text, so
%%c arrived as U+2300, which no SHX font has: other programs plotted a
diameter dimension in an SHX style without its sign. They now write
DimGeometry::mtext_contents(): the sign as %%c, which every font draws
(and CADCraft decodes), a % that would run into a code as %%%.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:32 -04:00
WillandBrandon Thomas a0dced6d45 fix(render): honor DIMDSEP in angular dimensions (#1670)
Use the effective dimension style's decimal separator when formatting
angular labels, preserving the public formatter's decimal-point default.

Add rendering and stored DXF/DWG block regression tests, and update the
file-format progress documentation.

Fixes #1646.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:16 -04:00
532c4ccc1c HATCH treats rooms enclosed by multilines as islands (#1642)
A pick inside a multiline wall found the band between the wall's lines
as the outer boundary, but the rooms the inner lines enclose were
dropped as islands: island_curve accepted lines, arcs, polylines and
splines, not multilines. The rooms were filled along with the walls. A
multiline's lines can now make up an island like the other open curves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:53:00 -04:00
CapriandGPT-6 Luna 88a73b7618 Render: Match AutoCAD clearance for DIMTAD 1 text (#2004) (#2027)
Co-authored-by: GPT-6 Luna <noreply@openai.com>
2026-10-24 01:52:16 -04:00
NetterSilverandClaude Haiku 5.5 0fc8e5241c Reject surveyor bearing angles outside 0-90 degrees (#2024)
Found while testing crates/engine/src/units.rs:279-294 (bearing, via parse_dms 297-317)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:52:11 -04:00
Marco Arnulfo cff2eb7262 Dimensions: text that does not fit follows DIMTMOVE, over the dimension line (with a leader for 1) instead of past the second extension line (#2015)
* Dimensions: text that does not fit follows DIMTMOVE, over the dimension line (with a leader for 1) instead of past the second extension line (fixes #1998)

* Dimensions: tighter check that the line is not carried out to moved text; drop a guard that never changed the outcome (#1998)
2026-10-24 01:52:05 -04:00
Marco Arnulfo ab6bff5e4e SETVAR CECOLOR takes the colour names and indexes the color command takes (fixes #1999) (#2014) 2026-10-24 01:52:00 -04:00
MarekSpodBiedry 1a76e10c20 hatch: list patternFile/patternText in the catalog and point to them from the unknown-pattern error (#2000) (#2007) 2026-10-24 01:51:53 -04:00
Jonasandcloudj 941ea341bc fix(ui): toggle QuickCalc with Ctrl/Cmd+8 (#2001)
Co-authored-by: cloudj <jonbahnes@gmail.com>
2026-10-24 01:51:47 -04:00
Antoine Boxho 71882cf5e6 constraints: bound combined expression and parameter depth (#1959) 2026-10-24 01:51:42 -04:00
64c35cadff Distinguish polyline midpoint grips from vertices (#1950) (#1958)
* ui: distinguish polyline midpoint grips

* docs: the midpoint-grip row belongs in the revision history (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-10-24 01:51:36 -04:00
MarekSpodBiedry 63dec85749 Let getkword return arbitrary input when initget bit 128 is set (#1945) (#1953) 2026-10-24 01:51:31 -04:00
MarekSpodBiedry 1c3c25035c List binary fractions in the Units precision drop-down for Architectural and Fractional (#1861) (#1952) 2026-10-24 01:51:26 -04:00
2bc3b02ea1 Make LISP angtof honour its units argument (#1949)
* Make LISP angtof honour its units argument

Found while testing crates/engine/src/lisp/drawing.rs:962-966

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

* angtof: a units argument outside 0..=4 gives nil instead of reading degrees (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-10-24 01:51:21 -04:00
Kshitij VarshneyandClaude Opus 5.5 6842b8774b File tabs: stop the close button from flickering (#1914)
* File tabs: the close button no longer flickers under the pointer

An inactive drawing tab drew its X only while the tab was hovered. Once
the pointer reached the X, egui gave the hover to the X, so the next frame
left the X out, the tab got the hover back, and the frame after drew the X
again. The tab background and its X flickered for as long as the pointer
stayed there, and a click could land on the tab instead of the X.

The tab now asks whether the pointer is inside its rectangle
(`Ui::rect_contains_pointer`), which the X does not change, both for the
hover fill and for showing the X.

Regression test: tests/file_tab_close.rs points at an inactive tab's X,
checks the X is there on each of the next frames, and clicks it to close
that drawing. It failed on the first frame before the fix.

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

* File tabs: plainer comment on why the tab checks the pointer

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:51:16 -04:00
78c1653f50 loops: implied closed loops from loose LINE and ARC chains (#1906)
* loops: implied closed loops from loose LINE and ARC chains

LOOPS is a read-only inquiry command that chains LINE and ARC endpoints
meeting within a tolerance. Closed loops report their member handles,
perimeter, area and winding (exact with arcs, like SHAPES); open chains
report the endpoint gap, where it is and their length; branch junctions
(three or more ends at one point) are reported but excluded from the
chains rather than resolved.

The tolerance defaults to 1e-9 x the extents diagonal and is an option.
The quadratic endpoint pairing is capped (maxPairs, 1,000,000 by default)
and reports capped/pairTestsNeeded instead of guessing from a partial
pairing. Degenerate lines and arcs are counted under skipped; full-circle
arcs close on their own. The command is read-only.

Fixes #1437

* loops: cap maxPairs at 100,000,000 so a caller can't ask for unbounded work

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:51:11 -04:00
fda82da3df RECTANG: add a Center Rectangle construction mode (#1895)
* RECTANG: add a Center Rectangle construction mode (#1127)

The first point of RECTANG was always a corner. A new Center option makes it
the rectangle's centre, so the second point (or Dimensions/Area) builds it
outward evenly: centre 10,10 with a corner at 14,16 gives 6,4 to 14,16.

- interactive: a Center keyword at the first prompt (Chamfer keeps the c
  shortcut; Center matches ce or the full word), which also changes the
  prompt to Specify center point for rectangle;
- JSON: center: true on rectang, keeping p1/p2 and the default corner mode
  unchanged;
- rotation turns about the centre; chamfer, fillet, width, elevation and
  thickness apply as before; the rubber band previews the centred rectangle;
- params documentation updated.

Tests: centre-point geometry for p2, dimensions and area, rotation, fillet,
chamfer, width, thickness, the interactive keyword and preview, and the
unchanged default corner mode.

Fixes #1127

* docs: document the functions touched by #1127

* RECTANG: the Center option is CEnter, so C stays Chamfer's shortcut (as PLINE)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:51:06 -04:00
NetterSilverandClaude Opus 5.5 2c59eb19c5 Fix LISP getangle subtracting ANGBASE in degrees from radians (#1883)
Found while testing crates/engine/src/lisp/input.rs:256-258 (lisp_angbase, used at :127 and :183); cause is crates/engine/src/sysvars.rs:141

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:50:56 -04:00
0299a3eaa0 fix(canvas): keep the OS cursor visible over menus and panels (#1879)
* fix(canvas): keep the OS cursor visible over menus and panels (fixes #1701)

The drawing area hid the OS cursor with `CursorIcon::None` whenever the
canvas response was hovered and the pointer lay inside the canvas rect.
egui still reports the canvas as hovered when the pointer is over a menu
popup's frame (the padding between its items and its border, and its
separators): only widgets claim the hover, so an open menu drawn over the
drawing left the canvas "hovered" and the cursor invisible there. The
same happened over a dialog's padding or a floating palette over the
canvas.

Require the topmost layer under the pointer to be the canvas's own layer
before treating the pointer as inside the drawing. Then menus, tooltips,
dialogs and floating panels keep a visible cursor (and the crosshair
stops under them, as over the menu bar already).

Tests: `crates/ui-egui/tests/cursor_over_menus.rs` (headless egui_kittest)
pins the canvas hiding the cursor, the menu bar and docked palettes
keeping it, the Show System Cursor option restoring the arrow off the
canvas, and the frame of an open menu over the drawing keeping the
cursor: the last case failed before this change. The whole
`cadcraft-ui-egui` test suite passes.

* docs: document the functions touched by #1701

* fix(canvas): keep the OS cursor visible over canvas-painted menus

Selection cycling's rows and Dynamic Input's options rows are menus, but
the canvas paints them itself: they share the canvas's layer, so
`layer_id_at` still reports the drawing and the OS cursor was hidden over
them (the review of #1701 caught it).

Exclude their rectangles from the crosshair's cursor contract, leaving the
canvas's input and the Dynamic Input tooltip under the pointer untouched
(so the list keeps highlighting and a click still picks its row).

Tests: two cases in `crates/ui-egui/tests/cursor_over_menus.rs` hover a
selection cycling row and a Dynamic Input options row and assert the OS
cursor stays visible; the drawing beside the list still hides it. Both
failed before this change.

* The painted menu frame counts as over the menu (CodeRabbit)

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

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:50:51 -04:00
Nicholas Gower 1c7d3f0fae CLI: a --cmd can act on a handle an earlier --cmd returned ({last}) (#1871)
* cli: act on a handle an earlier --cmd returned ({last}) (#1663)

A run's --cmd steps were independent: mview answered {"viewports":["100"]}
but viewport.set could not use that handle, so it failed with "no viewport
given".

The batch runner now remembers the last object handle a --cmd returned (the
strings in its result that name an entity of the active drawing, so counts and
names are not mistaken for handles). A parameter value of exactly {last} in a
later --cmd, at any depth, is replaced with it; every other value is passed
through unchanged. With no handle yet the run fails with a clear error instead
of passing a bad handle to the command.

Fixes #1663

* cli: keep {last} to the drawing and handle fields that returned it

Read handles only from result handle fields, never arbitrary strings: a group named like another object's handle is a name, not a reference. Clear the remembered handle when new, open, document.switch (or a script running one) makes another drawing current, and keep a handle the new drawing's own command returns.
2026-10-24 01:50:40 -04:00
Nicholas Gower 4f282b30cc OFFSET uses a single preselected object (#1870)
* OFFSET uses a single preselected object (#1610)

With exactly one offsettable object selected before OFFSET, take it and go
to the side prompt instead of asking to select an object again; a multiple
selection keeps the select-object loop. AutoCAD ignores preselection here,
but this is the requested behaviour for users coming from other tools.

* docs: document the functions touched by #1610
2026-10-24 01:50:34 -04:00
Nicholas Gower b9d59c64ab web: report the open drawing in the viewport title (#1869)
* web: report the open drawing in the viewport title

* docs: document the functions touched by #1717

* web: write the browser document title directly

eframe's web runner ignores egui::ViewportCommand::Title, so the tab and
an embedding page kept showing CADCraft when a drawing was opened,
renamed or became dirty. Write the title through web_sys instead, and
route the change decision through a writer function so the changed-title
path is covered by a native test.
2026-10-24 01:50:29 -04:00
oracola91 4c327cccd0 Autosave: a new autosave folder is owner-only on Unix (#1868)
Autosave files are unsaved drawings. The autosave folder was made with
create_dir_all, by the first write or for the lock file, so on Unix it
got the default mode and other accounts on the machine could read the
files. A folder the autosaver creates is now 0o700. A folder that already
exists keeps its mode, since SAVEFILEPATH can be any folder the user
picked.

The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
2026-10-24 01:50:24 -04:00
587d1dcd69 Spline extents: evaluate on the stack and sample relative to the spline (#1076) (#1834)
* Evaluate spline basis functions on the stack

`basis()` allocated three Vecs for every evaluated point, and a large
drawing evaluates millions of points while computing its extents on
open: `basis` was 10% and `calloc` 6% of `cadcraft-cli info` on the
1.1 M-entity drawing of #1076. `eval` now works in fixed arrays up to
degree 10 (the cap of the DXF reader and the SPLINE command) and falls
back to the heap above it. Results are bit-identical to the old code,
which the test keeps as its reference.

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

* Validate a spline once per tessellation

`tessellate` called `eval` for every sample, and `eval` re-ran
`is_valid` (a walk over the whole knot vector) and `domain()` each
time, so a spline with many control points cost O(knots) per point.
Validate and read the domain once, then evaluate the samples with the
checked variant. Part of the open time of large drawings (#1076).

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

* Size spline bounds sampling relative to the spline

`Spline::bounds` tessellated at an absolute tolerance of 1e-3, so every
spline of a drawing in millimetres hit the 4000-point cap: the extents
computed on open evaluated 12 M points for the 3072 five-point cubics
of the 1.1 M-entity drawing of #1076, a third of its open time in the
CLI and the GUI alike. Sample at 1e-3 of the spline's own control-box
diagonal instead, with at least four samples per knot span, straight
into the box without an intermediate Vec. The box of a typical spline
now takes a few hundred evaluations and stays within 1e-3 of the
spline's size of the finely sampled one (3.4e-4 at worst over the test
set); a degree-1 spline gets its exact control box.

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

* Give a spline with a non-finite control point its control box

`Bounds2::from_points` skips a non-finite point, so the control box
and its diagonal were finite while a NaN or infinite control point
poisoned every sample (`c * 0.0` is still NaN): `bounds` returned the
empty box instead of the box of the finite control points that the
other degenerate cases get. Check the control points before sampling.

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

* Size spline bounds sampling by a dimensionless ratio

`bounds` passed `diag * 1e-3` to `sample_count`, whose absolute floor
of 1e-9 on the tolerance made the count absolute again for a spline
with a diagonal under 1e-6: at a scale of 1e-12 every spline got the
minimum eight segments per degree whatever its shape, and an extreme
between two samples exceeded the 1e-3 margin the box promises. Derive
the count from the ratio of control-polygon length to tolerance in a
helper that `tessellate` (with its floor, unchanged) and `bounds`
(with the ratio to the diagonal) share, so a scaled copy of a spline
samples exactly like the original. The scale test now also runs at
1e-9 and 1e-12.

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

* Sample spline bounds over every knot span

`bounds` sampled one uniform grid over the parameter domain; the
four-per-span floor only raised the count. A narrow non-empty knot
span (a degree-2 peak supported on 0.5003..0.5004, say) fell between
grid points, at any count up to the cap, so the box missed part of
the curve and zoom extents clipped it. Spread the samples over the
non-empty knot spans in proportion to their length with a floor of
four per span, ends included, still folded straight into the box. The
cap holds over all spans: the floor drops to two or three when four
would exceed it, and a spline with more spans than two each allow
falls back to the grid. A typical spline evaluates as many points as
before.

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

* Hold a narrow knot span to eight samples, and say what that buys

The doc said the box falls short of an extreme by far under 1% of the
spline's size, which holds where the budget sets the count but not in a
span held to the floor: four segments across a degree-2 piece can miss
an off-grid peak by about 3% of the control box. Eight segments bring
that under 1%, and the comment now says both figures. A test puts the
peak between the grid's samples and checks the margin; four segments
would miss it by more.

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

* Give a spline with more spans than the cap samples its control box

Past two thousand knot spans the grid of the cap's four thousand
samples can step over a narrow span and leave its extreme out of the
box, which selection uses as a pre-filter. The control box contains the
curve whenever no weight is negative (the convex hull property), so
such a spline gets that box, conservative rather than sampled; only a
negative weight, which voids the property, keeps the grid.

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

* Keep the degree-1 shortcut for positive weights

A degree-1 spline is its control polygon only under the convex hull
property, which a negative weight voids: with control (1, 0), (2, 0)
and weights 1, -0.5 the curve passes (4, 0), outside the control box
that bounds() returned for every degree-1 spline. The shortcut, like
the many-spans one, now needs every weight positive; otherwise the
spline is sampled like any other.

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

* Sample a degenerate spline whose weights are not positive

Coincident control points with zero weights evaluate to the origin,
not to the point they sit on: eval() leaves the unnormalised sum alone
when the weights sum to nothing. The zero-diagonal shortcut returned
that point's box all the same. Like the degree-1 and many-spans
shortcuts, it now needs positive weights; otherwise the curve is
sampled, and the box holds what eval() gives.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 01:50:14 -04:00
philipp-tchandClaude Opus 5.5 e04056f714 The linetype scale of a block reference scales the linetypes inside the block (#1830)
A block reference's own linetype scale (DXF group 48) was ignored for the objects in the block:
a DASHED line inside a block inserted at linetype scale 2 kept dashes of scale 1. It now
multiplies their linetype scale, as LTSCALE does for the whole drawing (and as a dimension's
does for its block).


Claude-Session: https://claude.ai/code/session_01VP2GTYMskgGjiMw5GT5NL9

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:50:07 -04:00
25faf34d28 Interface Language dialog: list the languages alphabetically by their own name (#1787)
* Interface Language dialog: list the languages alphabetically by their own name

- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added

* Options dialog lists interface languages in the same order as the picker

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:49:59 -04:00
Books dadd1f227f AutoLISP: entmake defines blocks with BLOCK … ENDBLK (fixes #1662) (#1765)
* AutoLISP: entmake defines blocks with BLOCK ... ENDBLK (fixes #1662)

A BLOCK list wasn't an entity, so entmake returned nil and the entities
after it went into model space. Now a BLOCK list begins a definition
(name, flags, base point, description), the entities made meanwhile go
into it, and ENDBLK adds the block (redefining one of the same name, or
naming an anonymous *U block) and returns its name. An open definition,
an xref, an invalid name or a block that inserts itself is refused with
nil, using BLOCK's own name and self-reference checks.

* AutoLISP entmake blocks: review fixes (anonymous names keep a star, no redefinition during REFEDIT, a failed routine drops its open definition)

An anonymous block (flag 1) named without a * got a name like FO1; it now
gets *U. ENDBLK refuses to redefine a block REFEDIT is editing, as BLOCK
does. A routine that fails drops the definition it began, so the next
routine's entities go into the drawing; one that ends normally still
leaves it open.

* AutoLISP entmake blocks: refuse an anonymous block that inserts itself

The self-reference check ran only for named blocks, so an anonymous *U
block holding an INSERT of the name it was about to get (*U1 in an empty
drawing) was stored. The check now runs after the anonymous name is
given, for both kinds.

* AutoLISP entmake blocks: create a definition's layers only when the block is added

Each pending entity created its layer at once, so a definition ENDBLK
refused (a self-reference, REFEDIT) or a failed routine dropped left an
orphan layer. end_block now creates the layers after its checks pass,
just before it adds the block.
2026-10-24 01:49:54 -04:00
NetterSilverandClaude Haiku 5.5 1428174159 Fall back to decimal for huge architectural/fractional distances (#1762)
Found while testing crates/engine/src/units.rs:27-31 and 44-46

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:49 -04:00
NetterSilverandClaude Haiku 5.5 f686a595e6 Convert px/unitless SVG root size to mm when a viewBox is present (#1749)
Found while testing crates/io/src/svg_read.rs:576-593 (parse_length_mm), used at 605-612 (viewport)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:44 -04:00
NetterSilverandClaude Haiku 5.5 7d2fbd959a Reject repeated leading '#' in Rgb::parse_hex (#1746)
Found while testing crates/color/src/lib.rs:24

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:38 -04:00
829909274a Make properties.set apply thickness and properties report it (#1722) (#1744)
* Make properties.set apply thickness and properties report it (#1722)

* test: CIRCLE returns its new object as `handle`

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:49:33 -04:00
f8f13c3f4e Report DWGPREFIX, DWGTITLED, FILEDIA, CMDECHO and more from GETVAR (#1715) (#1742)
* Report DWGPREFIX, DWGTITLED, FILEDIA, CMDECHO and more from GETVAR (#1715)

* Keep CMDECHO and FILEDIA in session settings so they survive drawing switches (#1715)

* Leave LOGINNAME out of GETVAR so scripts and agents can't read the user's login name

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:49:28 -04:00
Will f119610287 fix(ui): keep cursors visible over layout backgrounds (#1733)
Choose crosshair and pickbox ink from the painted paper or surround
under the pointer, including while editing through a layout viewport.
Reuse the existing contrast rule and preserve model-space appearance.

Add paint-output regression tests for backgrounds, navigation,
space changes, and isometric and full-screen cursors.

Fixes #1685.
2026-10-24 01:49:21 -04:00
NetterSilverandClaude Haiku 5.5 620e6a664c Use AutoCAD value steps in the ACI index palette (#1732)
Found while testing crates/color/src/lib.rs:159

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:16 -04:00
NetterSilverandClaude Haiku 5.5 8dd8635e10 Read SVG arc flags as single characters in path data (#1726)
Found while testing crates/io/src/svg_read.rs:892-894 (arc flags read via Numbers::next_num, 675-709)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:12 -04:00
Patrik LermonandClaude Opus 5.5 4251f1e253 i18n(sv): translate 52 new strings, use the agreed terms in 19 (#1721)
* i18n(sv): translate 52 new strings, use the agreed terms in 19

Brings the Swedish catalog up to date with upstream main (0b32c2e).

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

* i18n(sv): use the agreed terms in 1

Brings the Swedish catalog up to date with upstream main (e86eacf).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:49:07 -04:00
NetterSilverandClaude Haiku 5.5 9a32aa5613 Reject doubled minus sign in parse_distance (#1707)
Found while testing crates/engine/src/units.rs:138-160

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-24 01:49:02 -04:00
6ee6e652fd fix(snap): keep an arc's sweep for TAN and PER snaps (#1698)
* fix(snap): keep an arc's sweep for TAN, PER and deferred snaps

An arc was snapped as its full circle: TAN and PER solutions on the circle
that fall outside the arc's sweep were offered as if they touched the arc,
and a deferred tangent lost the sweep when it was stored.

- SnapCurve gains an Arc variant carrying start and end, so a deferred snap
  keeps the sweep and resolves onto the arc.
- TAN and PER filter their solutions with Arc::contains_angle; PER clamps to
  the arc with Arc::closest instead of the circle's closest point.
- Tangents and perpendiculars between two deferred snaps are filtered on
  both ends.
- The viewport projection maps the new variant.
- Tests: a tangent in the gap is not offered, the perpendicular clamps to an
  end, and a deferred tangent keeps its sweep and solves onto the arc.

Fixes #1697

* fix(snap): correct direct PER, support Arc in deferred pairs

Addresses the review on #1698:
- Direct PER on an arc used `arc.closest(bp)`, which clamps a gap solution to an
  end and misses the valid opposite antipode; both antipodes are now offered,
  filtered to the arc's sweep.
- `deferred_pairs` matched only circles and lines, so any deferred pair with an
  arc operand fell through to None. An arc is now matched as its carrier circle
  for the math, with the sweep filter still applied to the solutions.
- Tests: the direct PER case now expects the antipode on the arc, and a
  TAN-TAN pair between an arc and a circle only keeps the tangent on the arc.

* test(snap): use a margin over the boundary in the arc PER test

Addresses the review on #1698: the cursor is 5 units from the antipode, so the
aperture is 5.5 rather than 5.0 to keep the distance comparison off the
boundary; the comment now describes the antipode rather than the nearest end.

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:48:57 -04:00
76f7cea83f i18n : Add new translations to zh.tsv (#1695)
* i18n:Add missing translations for the print dialog and page settings.

* i18n:Add missing translations for the print dialog and page settings.

* i18n(zh): drop rows that repeat existing keys and the unused 'Page setup of'

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

* Add new translations to zh.tsv

Added translations for some newly added features.

* i18n : Add missing hatch dialog translations

* Localize modal labels and button texts

* Refactor layout label for better localization

Update layout label to use internationalization for 'None'.

* Add various measurement units in Chinese to zh.tsv

* Refactor closing prompt to use crate translation

* zh: drop rows main already translates (duplicate keys fail the catalog test)

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

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:48:51 -04:00
47e978b58e DXF/DWG: keep a file's own arrowhead blocks with standard names (_Dot, _DotSmall) (#1694)
* DXF/DWG: keep a file's own arrowhead blocks with standard names

Reading dropped every block with a standard arrowhead name (`_Dot`,
`_DotSmall`, ...) that no INSERT used, since CADCraft regenerates its own on
save. A block only a dimension style names (DIMBLK) was dropped too, so
AutoCAD's `_Dot` (a polyline with width) or a user's redefinition came back
as CADCraft's disc of 24 SOLIDs. Now only blocks whose content is exactly what
CADCraft writes are dropped; any other content is kept and saved as it was,
through DXF and DWG. PURGE keeps arrowhead blocks that dimension styles or
dimension overrides name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Arrowheads: compare all properties, trim names in PURGE, test every standard arrow

is_generated_arrow now requires the whole Common to match, so only blocks
exactly as CADCraft writes them are regenerated. PURGE trims arrowhead names
from dimension styles and overrides. A new test writes every standard
arrowhead to DXF and DWG and checks none reads back as a user block. Review
of #1694.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
2026-10-24 01:48:46 -04:00
MarekSpodBiedry a68d0a458b Export to a browser download on the web instead of failing on a path (#1655) (#1682) 2026-10-24 01:48:40 -04:00
MarekSpodBiedry 60265a1eee Save a dimangular made from two lines as a 2-line angular dimension (#1652) (#1681) 2026-10-24 01:48:35 -04:00
MarekSpodBiedry 20b0c9c11a Show surveyor's angles as degrees/minutes/seconds (#1643) (#1676) 2026-10-24 01:48:30 -04:00
b517e0749d i18n(pt-BR): native-speaker terminology review (#1665)
* i18n(pt-BR): native-speaker terminology review

Fix mistranslations (Thaw Layer read "Ativar camada", Snap type and the
snap settings label were translated as object snap, Revolve read
"Revolucionar", "Sobre-restrito" spelling) and make the CAD vocabulary
consistent with Brazilian usage: "snap" for every snap mode, "viewport"
and "grip" kept as loanwords, "Multilinha", "Explodir".

Update the Portuguese row in localization-parity.md (it still read 0%)
and the pt-BR note in localization.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2

* i18n(pt-BR): align offset and layer on/off wording with Brazilian usage

Brazilian university AutoCAD handouts (UFSC, UFSCar, UFRGS) keep
Offset as a loanword and describe layers as ligar/desligar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2

* i18n(pt-BR): fix Bind, keep prompt keywords and SPDX in English

Bind merges an xref into the drawing, so Integrar, not Vincular.
Prompt keywords (Top/Zero/Bottom, Multiple) stay in English because
the command line only accepts the English keywords.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcdyVcguHgMao2p9C7rVf2

---------

Co-authored-by: Rodrigo Gressler <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 01:48:25 -04:00
Akmal Kamalov 5c911ad824 Fixed broken / malicious link in README.MD (#1640) 2026-10-24 01:48:20 -04:00
Vinicius AugustoandClaude Opus 5.5 15147260c1 INSERT from a file brings the file's multiline styles (#1639)
Inserting a DWG/DXF as a block merged its layers, linetypes and text,
dimension, multileader and table styles, but not its multiline styles.
A drawing that brings its wall styles in by inserting a library file
(AutoLISP add-ons do this: INSERT walls.dwg, ERASE the reference, then
MLINE with one of its styles) got "Cannot find multiline style". The
styles now merge like the other tables: a name the drawing already has
keeps the drawing's definition.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:48:15 -04:00
bottelliemanuele-makerandClaude Fable 5.1 bbbf4465c4 i18n(it): translate the 99 strings added since 0.6.2 (0.7.0 and 0.8.0) (#1617)
Multileaders, LISP, recent files, in-place reference editing, the
selection options (modes, preview, visual effects), Hatch and Gradient
(shape, origin, boundaries, islands), dynamic blocks, the Block Editor
and text formatting. Terminology follows the Italian desktop CAD
convention already used by it.tsv (grip, xrif, tratteggio, sfumatura,
multidirettrice, editor blocchi). Placeholders and ellipses unchanged;
uk.tsv coverage: 1350/1350.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 01:48:10 -04:00
skylartaylor 4a082b4c85 egui037: migrate CADCraft UI and preserve font notices 2026-10-24 06:30:17 +02:00
Dylan M. TaylorandCodebuff 7bf5905ac9 security: write the release from the release job only (SonarCloud S8233) (#2021)
The workflow-level contents: write granted every job the right to push
tags and releases; the reusable release workflow is the only job that
needs it. Workflow level drops to read.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 17:28:41 -04:00
SorinandClaude Opus 5.5 cda7381c3d Keep rectangle opposite corners free of ortho (STRETCH window and friends)
With ORTHOMODE on, the opposite corner of STRETCH's crossing window was bent
onto an axis, so the window collapsed and caught nothing. The prompt set only
the rubber-band base; it now marks it as a window corner (Prompt::corner), as
ZOOM Window and RECTANG already do.

Same fix for the other two-corner rectangles: the Window/Crossing/Box selection
modes, PLOTWINDOW, MVIEW (and its Restore), -VIEW Window and REVCLOUD
Rectangular. Regression tests in crates/engine/tests/stretch_window_ortho.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:17:25 +03:00
249 changed files with 18566 additions and 2631 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+5 -2
View File
@@ -11,7 +11,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -59,12 +59,15 @@ jobs:
release:
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: 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:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+5
View File
@@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
Generated
+422 -97
View File
@@ -13,11 +13,11 @@ dependencies = [
"byteorder",
"encoding_rs",
"flate2",
"foldhash",
"foldhash 0.2.0",
"indexmap",
"itoa",
"memmap2",
"nom",
"nom 7.1.3",
"oem_cp",
"once_cell",
"rayon",
@@ -28,9 +28,9 @@ dependencies = [
[[package]]
name = "accesskit"
version = "0.24.1"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"enumn",
"serde",
@@ -39,57 +39,51 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
version = "0.18.1"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"accesskit_consumer",
"atspi-common",
"phf 0.13.1",
"phf 0.14.0",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.36.0"
name = "accesskit_ios"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
version = "0.26.3"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -97,9 +91,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
version = "0.21.1"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -115,13 +109,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
version = "0.32.1"
version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -129,11 +123,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
version = "0.32.2"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
"accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -224,6 +219,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -434,6 +430,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-udev"
version = "0.1.2"
@@ -674,6 +676,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -729,6 +732,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"web-time",
]
[[package]]
@@ -868,6 +872,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
@@ -948,12 +958,26 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b"
dependencies = [
"egui",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
]
@@ -1086,9 +1110,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1097,9 +1120,8 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1122,6 +1144,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
"send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1134,9 +1157,8 @@ dependencies = [
[[package]]
name = "egui"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1156,9 +1178,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1176,32 +1197,35 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_glow"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1213,9 +1237,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"dify",
"eframe",
@@ -1240,9 +1263,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1302,16 +1324,15 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1321,16 +1342,17 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"smallvec",
"unicode-bidi",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
@@ -1416,6 +1438,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1427,18 +1455,42 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
dependencies = [
"num-traits",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.12.7"
@@ -1582,6 +1634,16 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1595,12 +1657,12 @@ dependencies = [
[[package]]
name = "glifo"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1755,6 +1817,15 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1763,7 +1834,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1772,7 +1843,40 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
[[package]]
@@ -1932,6 +2036,22 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2084,12 +2204,38 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"accesskit_consumer",
]
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"image-webp",
"imagesize",
"indexmap",
"pdf-writer",
"png",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path",
"xmp-writer",
"yoke",
"zune-jpeg",
]
[[package]]
@@ -2401,6 +2547,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2798,6 +2953,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2827,6 +2992,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pdf-writer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
dependencies = [
"bitflags 2.13.2",
"itoa",
"memchr",
"ryu",
]
[[package]]
name = "peniko"
version = "0.6.1"
@@ -2846,6 +3023,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -2857,33 +3045,33 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -2900,9 +3088,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3153,6 +3341,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"font-types 0.11.3",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
@@ -3160,7 +3358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.7",
"once_cell",
]
@@ -3171,8 +3369,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"font-types",
"foldhash 0.2.0",
"font-types 0.12.7",
"once_cell",
"yoke",
]
@@ -3354,6 +3552,12 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
version = "1.0.229"
@@ -3461,6 +3665,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"read-fonts 0.39.2",
]
[[package]]
name = "skrifa"
version = "0.44.0"
@@ -3604,6 +3818,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
dependencies = [
"kurbo",
"rustc-hash 2.1.3",
"skrifa 0.42.1",
"write-fonts",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -3758,6 +3995,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -3850,6 +4093,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -3894,6 +4148,21 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -3937,9 +4206,9 @@ dependencies = [
[[package]]
name = "vello_common"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -3952,9 +4221,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -4616,8 +4885,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -4689,6 +4957,37 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
dependencies = [
"font-types 0.11.3",
"indexmap",
"kurbo",
"log",
"read-fonts 0.39.2",
]
[[package]]
name = "writeable"
version = "0.6.4"
@@ -4758,6 +5057,12 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmp-writer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
@@ -5036,3 +5341,23 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+37 -6
View File
@@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"]
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
@@ -26,6 +26,9 @@ cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false }
# foundation
thiserror = "2"
@@ -50,11 +53,15 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
# 650b1c0 is the reviewed craft-libs integration: 25da's egui-0.37 port plus 6e7f's
# shared docking `Workspace`/`DockCommand`. Raw 6e7f is egui 0.36 and is not consumable here.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -80,3 +87,27 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+30 -4
View File
@@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
is available from crates.io and the repository above.
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
each under the SIL Open Font License 1.1.
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+6 -3
View File
@@ -123,8 +123,7 @@ numbers):
## Quick start
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
remains Rust 1.90.
CADCraft requires Rust 1.95 or later.
```sh
git clone https://github.com/storytold/cadcraft
@@ -141,7 +140,7 @@ offset 0.25 then pick the circle and a side
zoom e zoom to extents
```
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
### Fonts
@@ -219,6 +218,10 @@ cadcraft-cli convert drawing.dxf drawing.svg
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
# Act on what an earlier --cmd made: {last} is the last handle it returned.
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
```
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 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) |
@@ -135,6 +136,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
+190 -3
View File
@@ -28,7 +28,7 @@ macro_rules! outln {
use std::process::ExitCode;
use cadcraft_engine::doc::Space;
use cadcraft_engine::doc::{Handle, Space};
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{Session, View};
use serde_json::{Value, json};
@@ -73,7 +73,15 @@ image framing (png 2400x1600, svg, pdf; always model space):
scaled to fit the image without distortion.
--view extents always fit the drawing extents.
--view current the current view (as last set by zoom/pan/view.set; a freshly
opened drawing's view is its extents plus a 10% margin).";
opened drawing's view is its extents plus a 10% margin).
--cmd chaining (run):
A --cmd parameter value of exactly \"{last}\" is replaced with the last object handle an
earlier --cmd in the same run returned, so a command can act on what the previous one made:
--cmd 'mview {\"p1\":[20,20],\"p2\":[200,150]}' prints {\"viewports\":[\"100\"]}
--cmd 'viewport.set {\"handle\":\"{last}\",\"scale\":1}' changes viewport 100
It works in any parameter, at any depth (e.g. \"handles\":[\"{last}\"]); every other string is
passed unchanged. With no earlier returned handle the run fails and says so.";
/// What part of model space an image export shows.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -314,6 +322,133 @@ fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<Stri
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
/// through unchanged, and `--script` text is never rewritten.
const LAST_HANDLE: &str = "{last}";
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
const RESULT_DEPTH: usize = 16;
/// Result keys whose values are object references: the engine's result convention for handles.
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
fn handle_field(key: &str) -> bool {
matches!(
key,
"handle"
| "handles"
| "viewport"
| "viewports"
| "boundary"
| "clip"
| "insert"
| "members"
| "source"
| "objects"
| "entities"
| "selection"
| "parts"
| "refs"
| "matches"
| "line"
| "arc"
| "blocks"
)
}
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
/// or objects that carry handle fields of their own (`entities`, `parts`).
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::String(t) => {
if let Some(h) = Handle::parse_hex(t)
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
{
out.push(h);
}
}
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// Find handle fields nested in containers that are not handles themselves (`groups` in
/// `groups.list`, whose members are handles): strings here are names, never references.
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
if depth > RESULT_DEPTH {
return;
}
match v {
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
Value::Object(m) => {
for (k, x) in m {
if handle_field(k) {
collect_result_handles(s, x, depth + 1, out);
} else {
find_result_handles(s, x, depth + 1, out);
}
}
}
_ => {}
}
}
/// The object handles a command result mentions, in the order it lists them. A handle-only result
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
match v {
Value::String(_) => collect_result_handles(s, v, depth, out),
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
_ => {}
}
}
/// The last handle `r` returned: what a later `{last}` stands for.
fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
let mut hs = Vec::new();
result_handles(s, r, 0, &mut hs);
hs.last().copied()
}
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
/// drawing current.
fn active_drawing(s: &Session) -> Option<u64> {
s.docs.get(s.active).map(|st| st.uid)
}
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
/// error, never a token reaching the command as a (bad) handle.
fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, String> {
match p {
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
Some(h) => Ok(Value::String(h.hex())),
None => Err(format!("`{LAST_HANDLE}` needs an earlier --cmd in this run to have returned a handle")),
},
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
Value::Object(m) => {
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
}
other => Ok(other.clone()),
}
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
@@ -349,13 +484,31 @@ fn run(args: &[String]) -> Result<(), String> {
}
check_framed_output(framing, exp.as_deref())?;
let before = model_views(&s);
let mut last_handle: Option<Handle> = None;
for (kind, body) in steps {
// A handle belongs to the drawing that returned it: if this step makes another drawing
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
// so it is a handle of the drawing it made current.
let drawing = active_drawing(&s);
match kind.as_str() {
"script" => s.script(&body).map_err(|e| e.to_string())?,
"script" => {
s.script(&body).map_err(|e| e.to_string())?;
if active_drawing(&s) != drawing {
last_handle = None;
}
}
_ => {
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
let params = fill_last(&params, last_handle).map_err(|e| format!("--cmd {id}: {e}"))?;
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if active_drawing(&s) != drawing {
last_handle = None;
}
if let Some(h) = returned_handle(&s, &r) {
last_handle = Some(h);
}
if !r.is_null() {
outln!("{r}");
}
@@ -780,6 +933,40 @@ mod tests {
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
}
#[test]
fn last_placeholder_takes_the_previous_handle() {
let mut s = sample_session();
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let h = returned_handle(&s, &r).expect("LINE returns its handle");
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)), Ok(json!({"handle": h.hex()})));
// Nested, as in a selection list.
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)), Ok(json!({"handles": [h.hex(), "1"]})));
// Only the exact token: a name or a sentence that merely contains it stays as written.
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
assert_eq!(fill_last(&p, Some(h)), Ok(p.clone()));
}
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().contains("{last}"));
}
/// Handles are read from a result by looking the strings up in the drawing: counts and names
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
#[test]
fn returned_handle_reads_only_real_handles() {
let mut s = Session::empty();
s.new_drawing(false);
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
// The most recent of several.
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
}
#[test]
fn default_framing_is_extents() {
// convert: nothing ran, always the extents.
+225
View File
@@ -0,0 +1,225 @@
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
//!
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
use std::process::{Command, Output};
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("run cadcraft-cli")
}
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
/// handle the runner remembers.
#[test]
fn viewport_set_after_mview_uses_the_returned_handle() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"viewport.set {"handle":"{last}","scale":1}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
}
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
#[test]
fn last_without_an_earlier_handle_is_an_error() {
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
/// printed).
fn last_json(stdout: &str) -> serde_json::Value {
let line = stdout
.lines()
.rev()
.find(|l| {
let t = l.trim_start();
t.starts_with('{') || t.starts_with('[')
})
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
serde_json::from_str(line).expect("command result is JSON")
}
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
fn assert_needs_an_earlier_handle(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
/// object reference and must not replace the last handle the `line` command returned (`101`).
#[test]
fn a_group_name_that_looks_like_a_handle_is_not_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"group {"name":"100","handles":["101"]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
}
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
/// is not a handle of the new one.
#[test]
fn last_is_cleared_when_new_switches_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
#[test]
fn last_is_cleared_when_open_switches_drawings() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let dxf = dir.join("opened.dxf");
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
assert_needs_an_earlier_handle(&out);
let _ = std::fs::remove_dir_all(&dir);
}
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
/// drawing left behind must not be used in the one switched to.
#[test]
fn last_is_cleared_when_document_switch_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
#[test]
fn a_handle_returned_after_a_switch_is_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
}
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
/// remembered handle is cleared after a script that changed it.
#[test]
fn last_is_cleared_by_a_script_that_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--script",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
#[test]
fn ordinary_values_are_not_placeholders() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let out_dxf = dir.join("text.dxf");
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
"--save",
out_dxf.to_str().expect("temp path"),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
assert!(saved.contains("last and {last}"), "the text is stored literally");
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
[lints]
@@ -26,7 +27,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+2 -1
View File
@@ -95,7 +95,8 @@ impl Bridge {
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
+131 -12
View File
@@ -24,22 +24,38 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx);
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
@@ -51,10 +67,23 @@ mod web {
false
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
/// Writes the browser's document title, which names the tab and is what an embedding page
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
/// the title has to reach the DOM directly.
fn set_page_title(title: &str) {
match web_sys::window().and_then(|window| window.document()) {
Some(document) => document.set_title(title),
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
}
}
@@ -67,9 +96,11 @@ mod web {
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
/// drop a `.lsp` file rather than asking for a name it cannot read.
fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
}
pub fn start() {
@@ -152,7 +183,7 @@ mod web {
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
}),
)
.await;
@@ -177,6 +208,44 @@ fn wants_cpu(query: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
}
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
#[cfg(any(target_arch = "wasm32", test))]
fn document_title(name: Option<&str>, dirty: bool) -> String {
match name.filter(|n| !n.trim().is_empty()) {
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
None => "CADCraft".to_string(),
}
}
/// The title to write this frame, or `None` while the page already shows it: the browser needs
/// the document title only when it changes, not on every frame.
#[cfg(any(target_arch = "wasm32", test))]
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
let title = document_title(name, dirty);
if sent.as_deref() == Some(title.as_str()) {
return None;
}
*sent = Some(title.clone());
Some(title)
}
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
/// computed but not written leaves the tab on `CADCraft`.
#[cfg(any(target_arch = "wasm32", test))]
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
match title_to_send(sent, name, dirty) {
Some(title) => {
write(&title);
true
}
None => false,
}
}
#[cfg(target_arch = "wasm32")]
mod upload;
@@ -192,8 +261,10 @@ fn main() {
#[cfg(test)]
mod tests {
use super::wants_cpu;
use super::{document_title, push_title, title_to_send, wants_cpu};
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
#[test]
fn cpu_flag_is_detected() {
assert!(wants_cpu("?cpu"));
@@ -204,4 +275,52 @@ mod tests {
assert!(!wants_cpu("?webgl&sample"));
assert!(!wants_cpu("?gpucpu"));
}
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
#[test]
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
assert_eq!(document_title(None, false), "CADCraft");
assert_eq!(document_title(None, true), "CADCraft");
assert_eq!(document_title(Some(" "), true), "CADCraft");
}
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
#[test]
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
let mut sent = None;
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
// The page already has it: send nothing.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
// Unsaved changes add the bullet.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
// A rename shows the new name.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
// Saving clears it again.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
// Closing the last drawing leaves the app name only.
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
assert_eq!(title_to_send(&mut sent, None, false), None);
}
/// Guards the title write: every changed title reaches the writer (on the web, the browser
/// document title) exactly once, and an unchanged one is not written again. Without the write
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
#[test]
fn changed_title_reaches_the_document_title_writer_once() {
let mut sent = None;
let mut written: Vec<String> = Vec::new();
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unchanged: nothing is written again.
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unsaved changes are written.
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
// Closing the drawing writes the app name.
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
}
}
+2 -1
View File
@@ -68,8 +68,9 @@ impl Inbox {
}
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
pub fn take_dropped(&self, ctx: &egui::Context) {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
if let Some(file) = f.web_file() {
self.read(file.clone());
}
+2 -1
View File
@@ -4,6 +4,7 @@ description = "CADCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
default-run = "cadcraft"
@@ -16,7 +17,7 @@ cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+34 -14
View File
@@ -66,36 +66,44 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(false);
}
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
self.cad.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
self.persist_workspace(false);
}
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(true);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -143,6 +151,13 @@ fn services() -> Services {
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
}
}
@@ -273,6 +288,9 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
@@ -357,7 +375,9 @@ fn main() -> eframe::Result {
app.open_path(f);
}
if app.session.docs.is_empty() {
app.session.new_drawing(false);
// In the units the profile asks for (MEASUREINIT, restored above).
let metric = app.session.settings.measureinit;
app.session.new_drawing(metric);
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
+25 -2
View File
@@ -15,6 +15,9 @@ pub struct NativeMenu {
submenus: Vec<(String, Submenu)>,
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
/// ~300 ms of menu enablement.
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
@@ -66,7 +69,7 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
Self { _menu: menu, items, submenus, predefined, language, last_refresh: f64::NEG_INFINITY, recent: app.ui.recent_files.clone(), counter }
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -113,7 +116,7 @@ impl NativeMenu {
}
}
let now = cadcraft_ui_egui::now_ms();
if now - self.last_refresh < 300.0 {
if !refresh_due(now, self.last_refresh) {
return;
}
self.last_refresh = now;
@@ -124,6 +127,13 @@ impl NativeMenu {
}
}
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build(
parent: &Submenu,
entries: &[Entry],
@@ -150,3 +160,16 @@ fn build(
}
}
}
#[cfg(test)]
mod tests {
use super::refresh_due;
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+92 -13
View File
@@ -107,6 +107,23 @@ impl Filter {
*self = Self::default();
}
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
if !complete {
return;
}
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (b, raw) in bias.iter_mut().zip(self.raw) {
*b += (raw - *b) * 0.01;
}
self.bias = Some(*bias);
}
} else {
self.idle_since = None;
}
}
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
for (index, seen) in self.seen.iter_mut().enumerate() {
if mask & (1 << index) != 0 {
@@ -128,19 +145,7 @@ impl Filter {
}
let Some(mut bias) = self.bias else { return Motion::default() };
// Follow small resting drift only after two seconds entirely inside the deadzone.
if complete {
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (bias, raw) in bias.iter_mut().zip(self.raw) {
*bias += (raw - *bias) * 0.01;
}
self.bias = Some(bias);
}
} else {
self.idle_since = None;
}
}
self.update_drift(complete, now, &mut bias);
let mut axes = [0.0; 6];
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
@@ -156,3 +161,77 @@ impl Filter {
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_reports() {
assert_eq!(decode(&[]), None);
assert_eq!(decode(&[3]), None);
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
let values = [1_i16, -2, 3, 4, -5, 6];
let mut bytes = vec![1_u8];
for v in values {
bytes.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
bytes[0] = 2;
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
}
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
/// on a made-up point in time big enough that they sit in the past of later updates.
fn calibrated(t0: Instant) -> Filter {
let mut f = Filter::default();
for i in 0..17 {
let at = t0 + Duration::from_millis(50 * i);
assert_eq!(f.update([0; 6], 1, at), Motion::default());
assert_eq!(f.update([0; 6], 2, at), Motion::default());
}
assert!(f.calibrated());
f
}
#[test]
fn axes_deflect_and_expire() {
let t0 = Instant::now();
let mut f = calibrated(t0);
let at = t0 + Duration::from_secs(2);
let ms = Duration::from_millis;
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
assert!((m.pan[0] - 1.0).abs() < 1e-9);
assert!((m.pan[1] - 1.0).abs() < 1e-9);
assert_eq!(m.zoom, 0.0, "no rotation input");
// A fresh rotation report drives the zoom.
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
assert!((m.zoom - 1.0).abs() < 1e-9);
// The rotation axes went quiet for over 250 ms while translations still arrive.
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
assert_eq!(m.zoom, 0.0);
// A fresh rotation report puts the zoom back.
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
assert!((m.zoom - 1.0).abs() < 1e-9);
}
#[test]
fn drift_tracks_rest_after_two_idle_seconds() {
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
let t0 = Instant::now();
let mut b = [0.0; 6];
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
assert_eq!(b, [0.0; 6]);
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
assert_eq!(f.bias, Some(b));
// Motion outside the deadzone resets the idle window.
f.raw = [100.0; 6];
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
assert_eq!(f.idle_since, None);
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
}
}
+44 -5
View File
@@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Op
}
fn snapshot(ui: &UiState) -> Result<Value, String> {
ui.docking.validate()?;
cadcraft_ui_egui::docking::validate(&ui.docking)?;
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
return Err("toolsetTab must be Drafting or Modeling".into());
}
@@ -85,7 +85,7 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
if let Some(docking) = docking
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
&& docking.validate().is_ok()
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
{
ui.docking = docking;
}
@@ -236,7 +236,7 @@ mod tests {
#[test]
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::Panel;
use cadcraft_ui_egui::docking::{Panel, permissions};
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
@@ -244,19 +244,58 @@ mod tests {
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
ui.docking = app.ui.docking;
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
ui.show_toolsets = false;
store.save_if_changed(&ui).unwrap();
let (_, reloaded, error) = Workspace::at(Some(path));
assert!(error.is_none());
let mut reloaded = reloaded.unwrap();
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap();
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn version_one_docking_file_still_loads() {
use cadcraft_ui_egui::docking::{Panel, reveal_panel};
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
let file = r#"{"version":1,"workspace":{
"docking":{
"layout":{
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
"first":{"Tabs":{"panels":["canvas"],"active":0}},
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
"showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#;
let directory = temp_dir();
let path = directory.join("workspace.json");
fs::write(&path, file).unwrap();
let (_, restored, error) = Workspace::at(Some(path));
assert!(error.is_none(), "{error:?}");
let mut ui = restored.unwrap();
assert_eq!(ui.toolset_tab, "Modeling");
let docking = &ui.docking;
assert_eq!(docking.layout.floating.len(), 1);
let window = &docking.layout.floating[0];
assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]);
assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}}));
assert_eq!(
serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"],
json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}})
);
assert!(!docking.layout.contains(&Panel::ToolSets));
reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap();
let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(tools.anchor, Some(Panel::Canvas));
assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}]));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
let directory = temp_dir();
+18
View File
@@ -73,3 +73,21 @@ sync_github_name = false
[people.gurparvez]
real_name = "Gurparvez"
[people.nicholasgower]
sync_github_name = true
[people.ffishman]
real_name = "Rodrigo Gressler"
display_name = "Rodrigo"
sync_github_name = false
[people.aussieboer61]
real_name = "Richard Sauer"
display_name = "Richard Sauer"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+43 -8
View File
@@ -21,7 +21,8 @@ impl Rgb {
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -110,9 +111,9 @@ impl Color {
_ => {}
}
if let Some(rest) = t.strip_prefix("color ") {
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
}
if let Ok(i) = t.parse::<u16>() {
if let Some(i) = parse_digits::<u16>(&t) {
return match i {
0 => Some(Color::ByBlock),
256 => Some(Color::ByLayer),
@@ -123,14 +124,22 @@ impl Color {
if t.starts_with('#') {
return Rgb::parse_hex(&t).map(Color::True);
}
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
return Some(Color::True(Rgb(*r, *g, *b)));
}
None
}
}
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
pub fn aci_rgb(i: u8) -> Rgb {
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
@@ -156,7 +165,7 @@ fn build_palette() -> [Rgb; 256] {
for (slot, c) in p.iter_mut().zip(base) {
*slot = c;
}
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
for i in 10..250usize {
let hue = (i - 10) / 10; // 0..24, 15° each
let sub = i % 10;
@@ -233,13 +242,39 @@ mod tests {
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
}
#[test]
fn parse_rejects_plus_sign() {
assert_eq!(Color::parse("+5"), None);
assert_eq!(Color::parse(" +256"), None);
assert_eq!(Color::parse("color +5"), None);
assert_eq!(Color::parse("+1,+2,+3"), None);
assert_eq!(Color::parse("1,+2,3"), None);
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
}
#[test]
fn parse_hex_rejects_repeated_hash() {
assert_eq!(Rgb::parse_hex("##ff0000"), None);
assert_eq!(Rgb::parse_hex("###ff0000"), None);
assert_eq!(Color::parse("##ff0000"), None);
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
}
#[test]
fn palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
+58 -9
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
/// Longest expression accepted (characters).
pub const MAX_LEN: usize = 1024;
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
/// Deepest syntax tree and combined expression/parameter evaluation path.
pub const MAX_DEPTH: usize = 64;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
@@ -49,7 +49,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn ws(&mut self) {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
self.i += 1;
}
}
@@ -165,6 +165,20 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
if p.peek().is_some() {
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
}
let mut pending = vec![(&e, 1)];
while let Some((node, depth)) = pending.pop() {
if depth > MAX_DEPTH {
return Err(ExprError::TooDeep);
}
match node {
Expr::Neg(a) => pending.push((a, depth + 1)),
Expr::Bin(_, a, b) => {
pending.push((a, depth + 1));
pending.push((b, depth + 1));
}
_ => {}
}
}
Ok(e)
}
@@ -224,12 +238,19 @@ pub fn rename(text: &str, old: &str, new: &str) -> String {
/// Evaluate with a variable lookup.
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
eval_at_depth(e, &mut |n, _| lookup(n), 0)
}
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let v = match e {
Expr::Num(v) => *v,
Expr::Var(n) => lookup(n)?,
Expr::Neg(a) => -eval_with(a, lookup)?,
Expr::Var(n) => lookup(n, depth + 1)?,
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
Expr::Bin(op, a, b) => {
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
match op {
'+' => x + y,
'-' => x - y,
@@ -273,9 +294,12 @@ impl Env {
/// Value of a named parameter.
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
let mut stack = Vec::new();
self.value_inner(name, &mut stack)
self.value_inner(name, &mut stack, 0)
}
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
if let Some(v) = self.cache.get(name) {
return Ok(*v);
}
@@ -288,7 +312,7 @@ impl Env {
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?;
stack.push(name.to_string());
let r = eval_with(&e, &mut |n| self.value_inner(n, stack));
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
stack.pop();
let v = r?;
self.cache.insert(name.to_string(), v);
@@ -298,7 +322,7 @@ impl Env {
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
let e = parse(text)?;
let mut stack = Vec::new();
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
}
}
@@ -351,6 +375,31 @@ mod tests {
assert!(chain.value("p0").is_err());
}
#[test]
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
std::thread::Builder::new()
.stack_size(1024 * 1024)
.spawn(|| {
for op in ["+", "-", "*", "/"] {
let within = vec!["1"; MAX_DEPTH].join(op);
assert!(eval_const(&within).is_ok());
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
}
let mut env = Env::new();
for i in 0..MAX_DEPTH {
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
}
env.insert(&format!("p{MAX_DEPTH}"), "1");
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
env.insert("safe", "2*3");
assert_eq!(env.eval("safe+1"), Ok(7.0));
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+1 -1
View File
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
angle_tolerance: 1.0,
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
.iter()
.map(|s| s.to_string())
.map(ToString::to_string)
.collect(),
bars_visible: true,
bar_exceptions: Vec::new(),
+5
View File
@@ -66,6 +66,10 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>,
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
/// here, and saving renumbers it to the written CLASSES (#1812).
pub class_order: Vec<String>,
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
@@ -81,6 +85,7 @@ impl Foreign {
&& self.records.is_empty()
&& self.classes.is_empty()
&& self.targets.is_empty()
&& self.class_order.is_empty()
&& self.data_store.is_empty()
}
+16
View File
@@ -98,6 +98,9 @@ pub struct Drawing {
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
pub model: EntityStore,
pub layouts: Vec<Layout>,
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
pub active_layout: Option<String>,
pub views: Vec<NamedView>,
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
pub vports: Vec<VportConfig>,
@@ -171,6 +174,7 @@ impl Drawing {
blocks: BTreeMap::new(),
model: EntityStore::new(),
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
active_layout: None,
views: Vec::new(),
vports: Vec::new(),
ucss: Vec::new(),
@@ -298,6 +302,18 @@ impl Drawing {
true
}
/// The layouts in the order their paper space blocks are named in a file: the active one
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
let active = ls.remove(i);
ls.insert(0, active);
}
ls
}
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
match space {
Space::Model => Some(&self.model),
+22 -4
View File
@@ -177,6 +177,11 @@ pub struct DimStyle {
pub dim_line_color: Color,
pub ext_line_color: Color,
pub text_color: Color,
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
/// 2 `text_fill_color`.
pub text_fill: u8,
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
pub text_fill_color: Color,
pub text_style: String,
/// DIMPOST prefix/suffix "<>" template.
pub post: String,
@@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[
("DIMCLRD", "dimLineColor"),
("DIMCLRE", "extLineColor"),
("DIMCLRT", "textColor"),
("DIMTFILL", "textFill"),
("DIMTFILLCLR", "textFillColor"),
("DIMTXSTY", "textStyle"),
("DIMPOST", "post"),
("DIMCEN", "centerMark"),
@@ -367,6 +374,8 @@ impl Default for DimStyle {
dim_line_color: Color::ByBlock,
ext_line_color: Color::ByBlock,
text_color: Color::ByBlock,
text_fill: 0,
text_fill_color: Color::ByBlock,
text_style: "Standard".into(),
post: String::new(),
center_mark: 0.09,
@@ -476,10 +485,19 @@ impl DimStyle {
let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
}
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
Some(x) => serde_json::json!(x.max(0.0) as u64),
None => {
rejected.push(k.clone());
continue;
}
}
}
_ => coerced,
};
@@ -489,7 +507,7 @@ impl DimStyle {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
@@ -857,7 +875,7 @@ pub struct VportConfig {
impl VportConfig {
/// The name of the configuration shown.
pub const ACTIVE: &'static str = "*Active";
pub const ACTIVE: &str = "*Active";
pub fn is_active(&self) -> bool {
self.name.eq_ignore_ascii_case(Self::ACTIVE)
}
+4 -4
View File
@@ -109,9 +109,9 @@ impl Default for CellStyle {
}
impl CellStyle {
pub const TITLE: &'static str = "Title";
pub const HEADER: &'static str = "Header";
pub const DATA: &'static str = "Data";
pub const TITLE: &str = "Title";
pub const HEADER: &str = "Header";
pub const DATA: &str = "Data";
/// Whether `name` is one of the three styles every table style has.
pub fn is_builtin(name: &str) -> bool {
@@ -190,7 +190,7 @@ impl TableStyle {
/// The names of its cell styles: Title, Header, Data, then the custom ones.
pub fn cell_style_names(&self) -> Vec<String> {
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect();
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
out
}
+68
View File
@@ -541,12 +541,80 @@ mod native {
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
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}"))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
/// A style or scale name, or `default` when the drawing has none.
fn name_or_default(name: &str, default: &str) -> String {
let name = name.trim();
if name.is_empty() { default.to_string() } else { name.to_string() }
}
/// A drawing variable's value as text, or `default` when the header holds a value the
/// variable doesn't accept (a hostile file can put any integer in it).
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
if valid.contains(&value) { value.to_string() } else { default.to_string() }
}
/// Add the drawing variables dictionary AutoCAD always writes into the named object
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
/// read there without it, so newer output is left as it was, and a dictionary the drawing
/// does have keeps its own entries and values.
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
if doc.version >= acadrust::DxfVersion::AC1032 {
return;
}
let root_handle = doc.header.named_objects_dict_handle;
match doc.objects.get(&root_handle) {
Some(ObjectType::Dictionary(root)) => {
if root.get(VARIABLE_DICTIONARY).is_some() {
return;
}
}
_ => return,
}
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
// LAYERNOTIFY have no DXF header variable.
let variables = [
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
("LAYEREVAL", "0".to_string()),
("LAYERNOTIFY", "0".to_string()),
];
let dictionary_handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = root_handle;
for (name, value) in variables {
let handle = doc.allocate_handle();
let mut variable = DictionaryVariable::new(name, &value);
variable.handle = handle;
variable.owner_handle = dictionary_handle;
dictionary.add_entry(name, handle);
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
}
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
}
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
+15 -3
View File
@@ -302,7 +302,7 @@ mod native {
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
pub fn discard(path: &str) {
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
if !ours {
log::warn!("autosave: not deleting {path}: not an autosave file");
return;
@@ -334,10 +334,22 @@ mod native {
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
/// SAVEFILEPATH can be any folder the user picked.
fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
let bytes = write(doc, "autosave.dxf")?;
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
create_folder(dir).map_err(|e| e.to_string())?;
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
@@ -434,7 +446,7 @@ mod native {
}
self.last_beat = Some(now);
let dir = Path::new(dir);
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
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 {
log::warn!("autosave: lock file in {}: {e}", dir.display());
}
+11 -6
View File
@@ -182,7 +182,7 @@ fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
}
}
/// Angular dimension between two lines, attached to them (vertex = their intersection).
/// Two-line angular dimension (type 2) between two lines, attached to them.
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
let d = s.doc().ok()?;
let line = |h: Handle| match &d.entity(h)?.kind {
@@ -200,13 +200,18 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<E
};
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
let k = dim(s, DimKind::Angular3P, at, p1, p2, v, Vec2::ZERO, "");
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
let k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, "");
let mut assoc = Vec::new();
if assoc_enabled(s) {
assoc = vec![
DimAssoc { point: "p13".into(), handle: h1, snap: s1 },
DimAssoc { point: "p14".into(), handle: h2, snap: s2 },
DimAssoc { point: "p15".into(), handle: h1, snap: AssocSnap::Intersection { other: h2 } },
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
];
}
Some(with_assoc(k, assoc))
@@ -892,7 +897,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
}
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
+4 -2
View File
@@ -1,4 +1,4 @@
use cadcraft_doc::{AssocSnap, DimKind, Dimension, EntityKind, Handle};
use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
@@ -136,7 +136,9 @@ fn angular_between_lines_follows_rotation() {
let b = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
let d = h(&s.execute("dimangular", &json!({ "lines": [a.hex(), b.hex()], "at": [3, 3] })).unwrap());
let dm = dim(&s, d);
assert!(dm.assoc.iter().any(|x| matches!(x.snap, AssocSnap::Intersection { .. })));
// Two picked lines make a two-line angular dimension (type 2), tied to both lines at all four points.
assert_eq!(dm.kind, DimKind::Angular);
assert_eq!(dm.assoc.len(), 4);
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
// Swing the second line to 45°.
s.execute("properties.set", &json!({ "handles": [b.hex()], "end": [10, 10] })).unwrap();
+16 -6
View File
@@ -2,7 +2,7 @@
//! Block Editor.
pub(crate) mod bedit;
mod purge;
pub(in crate::cmd) mod purge;
use std::sync::Arc;
@@ -74,27 +74,36 @@ pub fn specs() -> Vec<CommandSpec> {
attribs::attdisp_spec(),
attribs::attsync_spec(),
burst::burst_spec(),
blockreplace::blockreplace_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
]
.into_iter()
.chain(attribs::attdisp_menu_specs())
.chain(xref::specs())
.chain(refedit::specs())
.chain(bedit::specs())
.collect()
}
fn valid_block_name(n: &str) -> bool {
pub(crate) fn valid_block_name(n: &str) -> bool {
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
}
/// The block an entity references, when it is a block reference.
/// The block an entity draws, if any: a block reference's block, a dimension's block or a
/// multileader's block content.
fn referenced_block(e: &Entity) -> Option<String> {
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
match &e.kind {
EntityKind::Insert(i) => Some(i.block.clone()),
EntityKind::Dimension(dm) => dm.block.clone(),
EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()),
_ => None,
}
}
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
/// Whether any of `ents` refers to block `name`, directly or through nested block references,
/// dimension blocks and multileader block content.
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
let mut seen = std::collections::HashSet::new();
while let Some(b) = todo.pop() {
@@ -878,3 +887,4 @@ impl Interactive for AttdefM {
mod attribs;
mod battman;
mod blockreplace;
+35 -11
View File
@@ -16,6 +16,22 @@ pub(super) fn attdisp_spec() -> CommandSpec {
CommandSpec::new("attdisp", "Attribute Display", run_attdisp)
.params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}")
.interactive(|_| Ok(Box::new(AttdispM)))
.transparent()
}
/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does.
pub(super) fn attdisp_menu_specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1))
.menu(&["View", "Attribute Display", "Normal"])
.params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"),
CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2))
.menu(&["View", "Attribute Display", "On"])
.params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"),
CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0))
.menu(&["View", "Attribute Display", "Off"])
.params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"),
]
}
pub(super) fn attsync_spec() -> CommandSpec {
@@ -84,7 +100,7 @@ impl Interactive for AttdispM {
/// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and
/// shown as defined, keeping the values of attributes whose tag (any case) is still defined.
/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD.
fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
let m = ins.transform(blk.base.xy());
defs.iter()
.map(|ad| {
@@ -119,12 +135,16 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, name: &str) -> Ve
/// Apply `f` to every reference of block `name` (in model space, layouts and other blocks);
/// returns how many references there were.
fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize {
let mut n = 0;
let mut update = |e: &mut Entity| {
update_reference_entities(d, name, |e| {
if let EntityKind::Insert(ins) = &mut e.kind {
f(ins);
}
};
})
}
/// [`update_references`], with `f` given the whole object.
pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize {
let mut n = 0;
let spaces: Vec<cadcraft_doc::Space> =
std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect();
for sp in spaces {
@@ -169,18 +189,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R
}))
}
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
/// Returns the block's name as defined and how many references were updated.
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let defs: Vec<&Attrib> = blk
.entities
/// The attribute definitions of `blk` that references carry (constant ones are not).
pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> {
blk.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(a) if !a.constant => Some(a),
_ => None,
})
.collect();
.collect()
}
/// Synchronize every reference of block `name` (in model space, layouts and other blocks).
/// Returns the block's name as defined and how many references were updated.
pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let defs = attdefs(&blk);
let d = s.doc_mut()?;
let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins));
Ok((blk.name.clone(), n))
@@ -0,0 +1,236 @@
//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space,
//! the layouts and other blocks, becomes a reference of another block at the same insertion
//! point, scale and rotation. The unreferenced block definition can then be purged.
use cadcraft_doc::{Drawing, Entity, EntityKind};
use serde_json::{Value, json};
use super::attribs::{attdefs, synced, update_reference_entities};
use super::purge::{override_arrows, used_blocks};
use super::references_block;
use crate::cmd::curves::KW;
use crate::cmd::machines::{SelOutcome, SelectPhase};
use crate::cmd::{CommandSpec, bad, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn blockreplace_spec() -> CommandSpec {
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
.menu(&["Tools", "Express Tools", "Replace Block..."])
.alias(&["-blockreplace"])
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
}
/// The defined name of block `name` (names are case-insensitive), when it is a named block of
/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved.
fn named_block(d: &Drawing, name: &str) -> Option<String> {
d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone())
}
/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their
/// property values in data that names the block.
fn not_replaceable(d: &Drawing, old: &str) -> Option<String> {
cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))
}
/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new`
/// reference itself.
fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option<String> {
let blk = d.block(new)?;
references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::<Vec<_>>(), old)
.then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))
}
/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s
/// definition when nothing references it afterwards. Returns (references replaced, purged).
fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> {
let cmd = "blockreplace";
if s.state()?.block_edit.is_some() {
return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first"));
}
let d = s.doc()?;
if d.refedit.is_some() {
return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first"));
}
if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) {
return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim())));
}
let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?;
let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?;
if old.eq_ignore_ascii_case(&new) {
return Err(bad(cmd, "the replacement block must be a different block"));
}
if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) {
return Err(bad(cmd, why));
}
let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?;
let defs = attdefs(&blk);
let scale = d.current_anno_scale();
let d = s.doc_mut()?;
let n = update_reference_entities(d, &old, |e| {
if let EntityKind::Insert(ins) = &mut e.kind {
ins.block.clone_from(&blk.name);
ins.attribs = synced(&blk, &defs, ins);
}
// A reference of an annotative block is annotative, as INSERT makes it.
if !blk.annotative {
e.common.anno_scales.clear();
} else if e.common.anno_scales.is_empty() {
e.common.anno_scales = vec![scale.clone()];
}
});
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned();
let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some());
Ok((n, purged))
}
/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions,
/// multileaders and block references of every other block definition, used or not.
fn referenced(d: &Drawing, name: &str) -> bool {
if used_blocks(d).contains(&name.to_ascii_uppercase()) {
return true;
}
let names = |e: &Entity| -> Vec<String> {
match &e.kind {
EntityKind::Insert(i) => vec![i.block.clone()],
EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(),
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
_ => Vec::new(),
}
};
d.blocks
.values()
.filter(|b| !b.name.eq_ignore_ascii_case(name))
.flat_map(|b| b.entities.iter())
.any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name)))
}
fn run_blockreplace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "blockreplace";
let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?;
let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?;
let purge = match p.get("purge") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))),
};
let (old, new) = (old.to_string(), new.to_string());
let (n, purged) = replace(s, &old, &new, purge)?;
let d = s.doc()?;
let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string());
Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged }))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Old,
New,
Select,
Purge,
}
#[derive(Default)]
struct BlockreplaceM {
stage: Stage,
/// The stage a picked reference answers.
back: Stage,
sel: SelectPhase,
old: String,
new: String,
}
impl BlockreplaceM {
/// Take `name` as the answer to the current name prompt. A block that cannot take part is
/// refused here, so the prompt asks again.
fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> {
let d = s.doc()?;
let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?;
if stage == Stage::Old {
if let Some(why) = not_replaceable(d, &name) {
return Err(EngineError::Other(why));
}
self.old = name;
self.stage = Stage::New;
} else {
if name.eq_ignore_ascii_case(&self.old) {
return Err(EngineError::Other("The replacement block must be a different block.".into()));
}
if let Some(why) = cannot_replace_with(d, &self.old, &name) {
return Err(EngineError::Other(why));
}
self.new = name;
self.stage = Stage::Purge;
}
Ok(())
}
fn finish(&mut self, s: &mut Session, purge: bool) -> Result<Step> {
let (n, purged) = replace(s, &self.old, &self.new, purge)?;
s.echo(format!(
"{n} reference{} of block {} replaced with {}.{}",
if n == 1 { "" } else { "s" },
self.old,
self.new,
if purged { format!(" Block {} purged.", self.old) } else { String::new() }
));
Ok(Step::Done)
}
}
impl Interactive for BlockreplaceM {
fn name(&self) -> &'static str {
"BLOCKREPLACE"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
stage @ (Stage::Old | Stage::New) => match &i {
Input::Keyword(k) if k == "?" => {
let d = s.doc()?;
let mut names: Vec<String> = d.blocks.keys().filter_map(|k| named_block(d, k)).collect();
names.sort_by_key(|n| n.to_ascii_lowercase());
s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) });
}
Input::Keyword(k) if k == "Select" => {
self.back = stage;
self.sel = SelectPhase::single();
self.stage = Stage::Select;
}
Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?,
Input::Enter | Input::Cancel => return Ok(Step::Done),
_ => {}
},
Stage::Select => match self.sel.feed(s, &i)? {
SelOutcome::More => {}
SelOutcome::Empty => self.stage = self.back,
SelOutcome::Done(hs) => {
s.set_selection(Vec::new());
self.sel = SelectPhase::single();
let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Insert(ins)) => Some(ins.block.clone()),
_ => None,
});
match block {
Some(b) => self.take(s, self.back, &b)?,
None => return Err(EngineError::Other("That object is not a block reference.".into())),
}
}
},
Stage::Purge => match &i {
Input::Keyword(k) if k == "No" => return self.finish(s, false),
Input::Keyword(_) | Input::Enter => return self.finish(s, true),
Input::Cancel => return Ok(Step::Done),
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
},
}
Ok(Step::Continue)
}
}
@@ -149,6 +149,7 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
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
@@ -255,4 +256,17 @@ mod tests {
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
assert!(file_ref(&s, "BOLT").is_none());
}
#[test]
fn inserted_file_brings_its_mline_styles() {
let mut src = Drawing::default();
src.mline_styles.push(cadcraft_doc::MLineStyle { name: "WALL".into(), ..Default::default() });
let mut d = Drawing::default();
merge_tables(&mut d, &src);
assert!(d.mline_styles.iter().any(|m| m.name == "WALL"));
// A style this drawing already has keeps its own definition.
let before = d.mline_styles.len();
merge_tables(&mut d, &src);
assert_eq!(d.mline_styles.len(), before);
}
}
+33 -7
View File
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
use super::super::{bad, find_command, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The named objects PURGE can remove. `USAGE` runs the same scan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
pub(in crate::cmd) enum Kind {
Blocks,
Dimstyles,
Groups,
@@ -117,14 +118,35 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
}
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
fn used_blocks(d: &Drawing) -> HashSet<String> {
/// The arrowhead block names a dimension's overrides set.
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
dm.overrides
.iter()
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
.filter_map(|(_, v)| v.as_str().map(|n| n.trim().to_string()))
.filter(|n| !n.is_empty())
}
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
/// arrowhead blocks dimension styles and dimension overrides name.
pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
let mut used = HashSet::new();
let mut frontier: Vec<String> = Vec::new();
for st in &d.dim_styles {
frontier.extend(
[&st.arrow_block, &st.arrow_block1, &st.arrow_block2, &st.leader_arrow_block]
.into_iter()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty()),
);
}
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
match &e.kind {
EntityKind::Insert(i) => frontier.push(i.block.clone()),
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
EntityKind::Dimension(dm) => {
frontier.extend(dm.block.clone());
frontier.extend(override_arrows(dm));
}
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
_ => {}
}
@@ -139,7 +161,10 @@ fn used_blocks(d: &Drawing) -> HashSet<String> {
for e in b.entities.iter() {
match &e.kind {
EntityKind::Insert(i) => frontier.push(i.block.clone()),
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
EntityKind::Dimension(dm) => {
frontier.extend(dm.block.clone());
frontier.extend(override_arrows(dm));
}
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
_ => {}
}
@@ -153,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
list.iter().any(|n| n.eq_ignore_ascii_case(name))
}
/// Names of the unused objects of a named kind, in table order.
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
/// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
/// reads it too, without purging anything.
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
match kind {
Kind::Blocks => {
+3 -1
View File
@@ -11,6 +11,8 @@ use super::pline_opts::{Outcome, PlineAsk};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
/// machine each command starts.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("line", "Line", run_line)
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2)
.menu(&["Draw", "Rectangle"])
.alias(&["rec", "rectangle"])
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}")
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?, center?: bool (p1 is the centre, not a corner; the size spans it evenly)}")
.interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))),
CommandSpec::new("polygon", "Polygon", run_polygon)
.menu(&["Draw", "Polygon"])
+49 -14
View File
@@ -1083,7 +1083,9 @@ impl Interactive for RevcloudM {
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
match (self.kind, self.pts.len()) {
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
(CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()),
(CloudKind::Rect, _) => {
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
}
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
@@ -1581,8 +1583,12 @@ fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64,
k
}
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("rectang", p, "p1")?;
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
let centered = bool_or(p, "center", false);
let rot = f64_or(p, "rotation", 0.0).to_radians();
let ch = p
.get("chamfer")
@@ -1618,17 +1624,18 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
} else {
let b = point_req("rectang", p, "p2")?;
let loc = (b - a).rotate(-rot);
(loc.x, loc.y)
// In centre mode the picked point is one corner, mirrored across the centre.
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
};
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
return Err(bad("rectang", "rectangle has no area"));
}
let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
let hd = s.add_entity(k)?;
// A rectangle has its own thickness option and does not take THICKNESS.
let th = f64_or(p, "thickness", 0.0);
if th != 0.0 {
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
}
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
added(hd)
}
@@ -1654,6 +1661,8 @@ enum RAsk {
#[derive(Default)]
pub(crate) struct RectM2 {
first: Option<Vec2>,
/// The first point is the rectangle's centre (the `Center` option), not a corner.
center: bool,
fillet: f64,
chamfer: (f64, f64),
width: f64,
@@ -1666,23 +1675,35 @@ pub(crate) struct RectM2 {
}
impl RectM2 {
/// 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) };
if w.abs() < 1e-12 || h.abs() < 1e-12 {
return Err(other("The rectangle has no area."));
}
let hd = s.add_entity(rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
if self.thickness != 0.0 {
let t = self.thickness;
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
}
let first = self.origin(w, h).unwrap_or(a);
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
let t = self.thickness;
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
Ok(Step::Done)
}
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
/// point itself as a corner, or the first point less half the size around the centre.
fn origin(&self, w: f64, h: f64) -> Option<Vec2> {
let a = self.first?;
Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a })
}
/// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only
/// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in
/// centre mode.
fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> {
let a = self.first?;
let loc = (p - a).rotate(-self.rotation);
Some(match self.dims {
Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }),
// In centre mode the picked point is one corner, mirrored across the centre.
None if self.center => (2.0 * loc.x, 2.0 * loc.y),
None => (loc.x, loc.y),
})
}
@@ -1692,6 +1713,8 @@ impl Interactive for RectM2 {
fn name(&self) -> &'static str {
"RECTANG"
}
/// 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 {
let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}"));
if let Some(a) = self.asking {
@@ -1717,10 +1740,15 @@ impl Interactive for RectM2 {
};
}
match self.first {
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
None => {
let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" };
Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"])
}
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
}
}
/// Consumes one input: option keywords (`Center` only switches the first point's meaning
/// to the centre and asks nothing), option values, and the points that size the rectangle.
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking {
let val = |i: &Input, default: f64| -> Result<f64> {
@@ -1794,6 +1822,11 @@ impl Interactive for RectM2 {
}
match i {
Input::Keyword(k) => {
// `Center` only switches the first point's meaning; it asks nothing.
if k == "CEnter" {
self.center = true;
return Ok(Step::Continue);
}
self.asking = Some(match k.as_str() {
"Chamfer" => RAsk::Chamfer1,
"Elevation" => RAsk::Elevation,
@@ -1820,12 +1853,14 @@ impl Interactive for RectM2 {
_ => Ok(Step::Continue),
}
}
/// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin
/// rules as the committed entity so centre mode previews where it will land.
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.asking.is_some() {
return Vec::new();
}
match (self.first, self.other_corner(c)) {
(Some(a), Some((w, h))) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
}
_ => Vec::new(),
+106
View File
@@ -566,6 +566,7 @@ fn xline_options_command_line() {
// ---------------- RECTANG ----------------
/// The corners of the rectangle just added, so a test can assert its bounds.
fn rect_pts(s: &Session) -> Vec<Vec2> {
match last(s) {
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
@@ -622,6 +623,111 @@ fn rectang_options_command_line() {
}
}
/// Guards the JSON `center: true` form for every sizing route: a picked corner mirrored about
/// the centre, full-size `dimensions`, full `area`, rotation about the centre, that the default
/// corner mode is untouched, and that centre mode without a size is rejected.
#[test]
fn rectang_center_mode_json() {
let mut s = Session::new();
// The first point is the centre; the second is a corner mirrored about it.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16]})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Dimensions are the rectangle's full size, still centred on the first point.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12]})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// A second point only picks the quadrant.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "p2": [0, 0]})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// The area is the full area, centred too.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "area": 96, "length": 8})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Rotation is about the centre.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "rotation": 90})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 4.0, 6.0) && near(b.max, 16.0, 14.0), "{b:?}");
// The default corner mode is unchanged.
s.execute("rectang", &json!({"p1": [10, 10], "p2": [14, 16]})).unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Centre mode still needs a size.
assert!(s.execute("rectang", &json!({"center": true, "p1": [10, 10]})).is_err());
}
/// Guards that fillet, chamfer, width and thickness still apply in centre mode, and that an
/// oversized fillet falls back to sharp corners without moving the rectangle off centre.
#[test]
fn rectang_center_mode_honours_options() {
let mut s = Session::new();
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 1, "width": 0.25, "thickness": 2})).unwrap();
match &s.doc().unwrap().model.last().unwrap().kind {
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.25),
k => panic!("{k:?}"),
}
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Chamfer, still centred.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "chamfer": [1, 1]})).unwrap();
assert_eq!(rect_pts(&s).len(), 8);
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// A fillet too large for the rectangle falls back to sharp corners, still centred.
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 5})).unwrap();
assert_eq!(rect_pts(&s).len(), 4);
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
}
/// Guards the `Center` keyword at the command line, before or after other options, with
/// dimensions and area still centred, the prompt wording and centred rubber band, and that the
/// default corner mode is unchanged.
#[test]
fn rectang_center_mode_command_line() {
let mut s = Session::new();
s.cmdline("rectang ce 10,10 14,16").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Options before the centre point, and the centre picked after them.
s.cmdline("rectang center f 1 w 0.1 10,10 14,16").unwrap();
match &s.doc().unwrap().model.last().unwrap().kind {
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.1),
k => panic!("{k:?}"),
}
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Dimensions after the centre: the pick only chooses the quadrant.
s.cmdline("rectang ce 10,10 d 8 12 14,16").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// Area too: the rectangle of that area is centred on the first point.
s.cmdline("rectang ce 10,10 a 96 l 8").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
// The rubber band is centred while the second point is picked.
s.start("rectang").unwrap();
s.cmdline("ce").unwrap();
assert!(s.prompt_text().contains("Specify center point for rectangle"), "{}", s.prompt_text());
s.cmdline("10,10").unwrap();
let pv = s.preview(Vec2::new(14.0, 16.0));
let pb = match pv.first().map(|e| &e.kind) {
Some(EntityKind::LwPolyline(p)) => cadcraft_geom::Bounds2::from_points(p.vertices.iter().map(|v| v.p).collect::<Vec<_>>()),
k => panic!("{k:?}"),
};
assert!(near(pb.min, 6.0, 4.0) && near(pb.max, 14.0, 16.0), "{pb:?}");
s.cancel();
// The default corner mode is unchanged.
s.cmdline("rectang 10,10 14,16").unwrap();
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
}
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
#[test]
+62 -11
View File
@@ -26,6 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
.key("Cmd+X")
.params("{handles?}")
.interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))),
CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase)
.menu(&["Edit", "Cut with Base Point"])
.params("{handles?, base: [x,y]}")
.interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))),
CommandSpec::new("copyclip", "Copy", run_copyclip)
.menu(&["Edit", "Copy"])
.key("Cmd+C")
@@ -434,9 +438,11 @@ impl Interactive for MredoM {
}
}
/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it.
fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<usize> {
let d = s.doc()?;
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
let mut seen = std::collections::HashSet::new();
let ents: Vec<Entity> = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
let n = ents.len();
@@ -451,22 +457,59 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_copybase(s: &mut Session, p: &Value) -> Result<Value> {
let base = point_req("copybase", p, "base")?;
let hs = targets(s, p)?;
let base = point_param(p, "base");
let n = copy_to_clip(s, &hs, base)?;
let n = copy_to_clip(s, &hs, Some(base))?;
Ok(json!({ "copied": n }))
}
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
let mut hs = targets(s, p)?;
hs.retain(|h| !super::curves::is_locked(s, *h));
let n = copy_to_clip(s, &hs, None)?;
/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how
/// many were cut and the message to show: how many were on a locked layer, and that nothing was
/// cut when that is all of them (the clipboard then keeps what it held).
fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<(usize, Option<String>)> {
let mut seen = std::collections::HashSet::new();
let live: Vec<Handle> = {
let d = s.doc()?;
hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect()
};
let (hs, locked): (Vec<Handle>, Vec<Handle>) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h));
let note = match locked.len() {
0 => None,
1 => Some("1 object was on a locked layer.".to_string()),
n => Some(format!("{n} objects were on a locked layer.")),
};
if hs.is_empty() {
let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut."));
return Ok((0, Some(msg)));
}
let n = copy_to_clip(s, &hs, base)?;
let d = s.doc_mut()?;
for h in &hs {
d.remove_entity(*h);
}
s.set_selection(Vec::new());
Ok(json!({ "cut": n }))
Ok((n, note))
}
fn cut_result(n: usize, msg: Option<String>) -> Value {
let mut r = json!({ "cut": n });
if let Some(m) = msg {
r["message"] = json!(m);
}
r
}
fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let (n, msg) = cut_to_clip(s, &hs, None)?;
Ok(cut_result(n, msg))
}
fn run_cutbase(s: &mut Session, p: &Value) -> Result<Value> {
let base = point_req("cutbase", p, "base")?;
let hs = targets(s, p)?;
let (n, msg) = cut_to_clip(s, &hs, Some(base))?;
Ok(cut_result(n, msg))
}
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
@@ -649,23 +692,31 @@ impl Interactive for SelectM {
}
}
/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection).
/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst
/// selection). CUTBASE also erases the objects it copies.
#[derive(Default)]
struct CopyBaseM {
base: Option<Vec2>,
sel: SelectPhase,
cut: bool,
}
impl CopyBaseM {
fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
copy_to_clip(s, hs, self.base)?;
if self.cut {
if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? {
s.echo(m);
}
} else {
copy_to_clip(s, hs, self.base)?;
}
Ok(Step::Done)
}
}
impl Interactive for CopyBaseM {
fn name(&self) -> &'static str {
"COPYBASE"
if self.cut { "CUTBASE" } else { "COPYBASE" }
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
+33 -2
View File
@@ -85,11 +85,25 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
}
#[cfg(target_arch = "wasm32")]
{
let _ = (path, bytes);
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`"))
deliver_file(cmd, path, bytes, super::file::deliver())
}
}
/// No file system on the web: hand the bytes to the host's delivery hook (a browser download)
/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it.
#[cfg(any(test, target_arch = "wasm32"))]
pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option<super::file::DeliverHook>) -> Result<()> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let mime = match extension(&name).as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"pdf" => "application/pdf",
_ => "application/octet-stream",
};
let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?;
deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))
}
// ---------- EXPORT ----------
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -545,6 +559,23 @@ mod tests {
use super::super::file::{IoHooks, set_io};
use super::*;
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> {
DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len()));
Ok(())
}
#[test]
fn web_export_delivers_a_download_instead_of_writing_a_path() {
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap();
let got = DELIVERED.lock().unwrap().clone();
assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]);
let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string();
assert!(e.contains("base64"), "{e}");
}
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
// Same output as the layout tests' stand-in: whichever test installs first wins.
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
+10 -2
View File
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["File", "New Drawing..."])
.key("Cmd+N")
.alias(&["qnew"])
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
.enabled(always)
.noundo(),
CommandSpec::new("open", "Open...", run_open)
@@ -252,7 +252,8 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = template::run_new(s, p)? {
return Ok(r);
}
let i = s.new_drawing(bool_or(p, "metric", false));
let metric = s.settings.measureinit;
let i = s.new_drawing(bool_or(p, "metric", metric));
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
}
@@ -375,6 +376,7 @@ fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
s.bump_doc_epoch();
ok()
}
@@ -382,6 +384,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
s.cancel();
s.docs.clear();
s.active = 0;
s.bump_doc_epoch();
ok()
}
@@ -493,6 +496,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
}
s.cancel();
s.active = i;
// Bump even when `i` is already active. This is intentionally conservative: the command
// cancels the active command, and a screenshot client can only see observed state, not
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
// an image whose queueing identity the client cannot distinguish from a real switch.
s.bump_doc_epoch();
ok()
}
+62 -6
View File
@@ -2,7 +2,7 @@
//! drawing). Objects are matched by geometry and properties first (preferring the same handle),
//! then by handle: the same handle with other geometry or properties is a change. What is left
//! is only in this drawing or only in the other. The comparison stays on the session for the
//! canvas overlay and next/previous navigation until COMPARECLOSE.
//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
@@ -16,6 +16,8 @@ use super::super::*;
use super::{base64_decode, io};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod export;
/// Objects compared per drawing; more are left out (and the result says so).
pub const MAX_OBJECTS: usize = 200_000;
/// Objects listed per kind of difference in the JSON result.
@@ -36,6 +38,9 @@ pub struct Comparison {
pub other_name: String,
/// The other drawing (its objects are drawn where they differ).
pub other: Arc<Drawing>,
/// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref
/// paths from that file's folder.
pub other_path: Option<String>,
/// Only in this drawing (handles here).
pub added: Vec<Handle>,
/// Only in the other drawing (handles there).
@@ -71,6 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(),
CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(),
CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(),
export::spec(),
]
}
@@ -133,11 +139,23 @@ struct Signer<'a> {
d: &'a Drawing,
blocks: HashMap<String, u64>,
work: usize,
/// When set, a block's signature also covers its base point, units, annotative and overlay
/// flags and xref file, with relative xref, image and data link paths taken from the folder
/// given, and the
/// signatures of a multileader and a dimension cover their blocks'. Each object in a block
/// then also counts with its visibility, transparency, thickness and extrusion, and an
/// anonymous block's signature leaves out its name (a dimension also leaves its block's name
/// out of its own geometry; an INSERT or MLEADER keeps it).
frames: Option<Option<&'a std::path::Path>>,
}
impl<'a> Signer<'a> {
fn new(d: &'a Drawing) -> Self {
Signer { d, blocks: HashMap::new(), work: 0 }
Signer { d, blocks: HashMap::new(), work: 0, frames: None }
}
/// A signer whose block signatures also cover what changes how a reference draws the block.
fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self {
Signer { frames: Some(folder), ..Signer::new(d) }
}
fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -156,6 +174,18 @@ impl<'a> Signer<'a> {
if let EntityKind::Insert(i) = k {
self.block(&i.block, depth).hash(&mut h);
}
if self.frames.is_some()
&& let EntityKind::MLeader(m) = k
&& let Some(b) = &m.block
{
self.block(&b.block, depth).hash(&mut h);
}
if self.frames.is_some()
&& let EntityKind::Dimension(dm) = k
&& let Some(b) = &dm.block
{
self.block(b, depth).hash(&mut h);
}
h.finish()
}
fn block(&mut self, name: &str, depth: usize) -> u64 {
@@ -164,17 +194,42 @@ impl<'a> Signer<'a> {
return *v;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
let block = self.d.block(name);
// With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the
// same block can have another generated name in another drawing. Only a dimension's
// geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references.
if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) {
key.hash(&mut h);
}
if depth < MAX_BLOCK_DEPTH
&& let Some(b) = self.d.block(name)
&& let Some(b) = block
{
if let Some(folder) = self.frames {
let mut xref = b.xref_path.clone();
if let Some(x) = xref.as_mut() {
export::rebase_path(x, folder);
}
(format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h);
}
for e in b.entities.iter() {
if self.work >= MAX_BLOCK_WORK {
break;
}
self.work += 1;
self.geometry(&e.kind, depth + 1).hash(&mut h);
match self.frames {
// An image's file and a table's data link, from the folder given.
Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => {
let mut k = e.kind.clone();
export::rebase(&mut k, folder);
self.geometry(&k, depth + 1).hash(&mut h);
}
_ => self.geometry(&e.kind, depth + 1).hash(&mut h),
}
properties(e).hash(&mut h);
if self.frames.is_some() {
let c = &e.common;
format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h);
}
}
}
let v = h.finish();
@@ -311,6 +366,7 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec<Value> {
fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
let (other, name) = other_drawing(s, p)?;
let other_path = export::source_path(s, p, &other);
let st = s.state()?;
let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone());
if Arc::ptr_eq(&cur, &other) {
@@ -361,7 +417,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
"message": message,
});
let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect();
s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None });
s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None });
s.touch();
Ok(out)
}
@@ -0,0 +1,684 @@
//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again
//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds
//! copies of the other drawing's objects that differ, then copies of the current drawing's
//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects
//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the
//! other drawing or changed there in its red, and objects in both in gray (color 8). A block
//! reference, dimension or multileader refers to a copy of its block whose objects all have that
//! color (`COMPARE_CURRENT_<name>`, `COMPARE_COMPARED_<name>`, `COMPARE_COMMON_<name>`; for a
//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy
//! of its style; xrefs keep their own colors. The new
//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other
//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of
//! the other drawing whose name this one uses is copied under a free name. Relative xref, image
//! and data link paths become absolute paths from the folder of the drawing they came from. It
//! opens untitled as `compare_<this> vs <other>.dwg`, with unsaved changes; saving it asks for a
//! file name.
use std::collections::{HashMap, HashSet};
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 serde_json::{Value, json};
use super::super::super::{CommandSpec, bad};
use super::{Comparison, MAX_OBJECTS};
use crate::{Result, Session};
/// Only in this drawing, or changed in it (the overlay's color).
const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
/// Only in the other drawing, or changed there.
const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
/// In both drawings.
const COMMON: Color = Color::Index(8);
/// Block definitions copied from the other drawing (the ones they reference included), and
/// recolored copies of blocks and styles, per export.
const MAX_BLOCKS: usize = 10_000;
/// Objects copied into block definitions (the other drawing's blocks and the recolored
/// copies), per export.
const MAX_COPIED: usize = 1_000_000;
pub(super) fn spec() -> CommandSpec {
CommandSpec::new("compareexport", "Export Comparison", run)
.params(
"{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \
as a new drawing, opened untitled",
)
.noundo()
}
/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds,
/// whether a drawing had more objects than were compared, whether the other drawing had more
/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors.
struct Snapshot {
drawing: Drawing,
current: String,
compared: String,
counts: (usize, usize),
truncated: bool,
blocks_left_out: bool,
uncolored: bool,
}
fn run(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.state()?;
let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref()));
let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?;
let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name));
let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) };
let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders);
let i = s.open_drawing(drawing, &name, None);
// Not saved yet: closing it asks to save it under a name.
let st = s.state_mut()?;
st.saved = Arc::new((*st.doc).clone());
let mut notes = Vec::new();
if truncated {
notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared"));
}
if blocks_left_out {
notes.push("some blocks of the other drawing were too many or too large to copy".to_string());
}
if uncolored {
notes.push("some blocks keep their own colors".to_string());
}
let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
Ok(json!({
"index": i,
"title": name,
"current": current,
"compared": compared,
"counts": { "current": counts.0, "compared": counts.1 },
"truncated": truncated || blocks_left_out || uncolored,
"message": format!("The comparison was exported to the new drawing {name}{notes}."),
}))
}
/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open
/// drawing's own file. `None` for `{data}` and for an open drawing never saved.
pub(super) fn source_path(s: &Session, p: &Value, other: &Arc<Drawing>) -> Option<String> {
match p.get("path").and_then(Value::as_str) {
Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()),
_ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()),
}
}
/// The folders of the two drawings' files. The snapshot has no folder of its own, so the
/// relative file paths it copies (xrefs, images, table data links) become absolute paths from
/// the folder of the drawing they came from.
struct Folders {
here: Option<PathBuf>,
there: Option<PathBuf>,
}
/// The absolute folder of the file at `path`.
fn folder_of(path: Option<&str>) -> Option<PathBuf> {
let parent = Path::new(path?).parent()?;
let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent };
Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf()))
}
/// `p` as an absolute path from `folder` when it is relative.
pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) {
if let Some(folder) = folder
&& !p.trim().is_empty()
&& Path::new(p.as_str()).is_relative()
{
let joined = folder.join(p.as_str());
*p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string();
}
}
/// Rebase the file an object refers to: an image's file, a table's data link.
pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) {
match k {
EntityKind::Image(i) => rebase_path(&mut i.path, folder),
EntityKind::Table(t) => {
if let Some(l) = t.data_link.as_mut() {
rebase_path(&mut l.path, folder);
}
}
_ => {}
}
}
/// Rebase a block's xref path and the files its objects refer to.
fn rebase_block(b: &mut Block, folder: Option<&Path>) {
if let Some(x) = b.xref_path.as_mut() {
rebase_path(x, folder);
}
let mut store = EntityStore::new();
for e in b.entities.iter() {
let mut e = (**e).clone();
rebase(&mut e.kind, folder);
store.push(e);
}
b.entities = store;
}
/// Whether a block holds a relative file path that [`rebase_block`] would change.
fn has_relative_paths(b: &Block) -> bool {
let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative();
b.xref_path.as_deref().is_some_and(rel)
|| b.entities.iter().any(|e| match &e.kind {
EntityKind::Image(i) => rel(&i.path),
EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)),
_ => false,
})
}
/// A drawing title without its file extension.
fn stem(title: &str) -> String {
std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string())
}
fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
let other: &Drawing = &c.other;
let mut d = cur.clone();
if folders.here.is_some() {
for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) {
rebase_block(Arc::make_mut(b), folders.here.as_deref());
}
}
d.model = EntityStore::new();
for l in &mut d.layouts {
l.entities = EntityStore::new();
}
d.foreign = Arc::default();
d.groups.clear();
d.constraints.clear();
// The per-object constraint bar and dimension exceptions name objects the copy doesn't have.
d.parametric.settings.bar_exceptions.clear();
d.parametric.settings.dim_exceptions.clear();
d.hidden_objects.clear();
d.read_notes.clear();
d.refedit = None;
d.skipped_records.clear();
d.handseed = d.handseed.max(other.handseed);
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);
merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name);
merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name);
merge(&mut d.table_styles, &other.table_styles, |t| &t.name);
merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name);
// Compared again, so edits made since COMPARE ran are classified as they are now.
let diff = super::diff(cur, other, &c.space);
let ours: HashSet<Handle> = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect();
let here: Vec<(&Entity, Color)> = cur
.space(&c.space)
.into_iter()
.flat_map(EntityStore::iter)
.filter(|e| !matches!(e.kind, EntityKind::Viewport(_)))
.take(MAX_OBJECTS)
.map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON }))
.collect();
// In the other drawing's draw order.
let theirs: HashSet<Handle> = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect();
let there: Vec<(&Entity, Color)> = other
.space(&c.space)
.into_iter()
.flat_map(EntityStore::iter)
.filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_)))
.map(|e| (e.as_ref(), OTHER))
.collect();
let mut tinted = Tinted::default();
let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted);
// The other drawing's objects first, so this drawing's are drawn over them.
let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted);
let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted);
let uncolored = tinted.uncolored;
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();
for (e, color) in objs {
d.ensure_layer(&e.common.layer);
let h = d.new_handle();
let mut copy = Entity { handle: h, ..(*e).clone() };
detach(&mut copy.kind);
rebase(&mut copy.kind, folder);
rename_refs(&mut copy.kind, renames);
paint(d, &mut copy, *color, tinted, 0);
d.model.push(copy);
members.push(h);
}
let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n)));
if !members.is_empty() {
d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members });
}
name
}
/// Drop the links of an associative dimension or hatch to the objects it follows: the copies
/// have new handles, so the links would name objects the snapshot does not have.
fn detach(k: &mut EntityKind) {
match k {
EntityKind::Dimension(dm) => dm.assoc.clear(),
EntityKind::Hatch(h) => {
h.associative = false;
h.loops.iter_mut().for_each(|l| l.sources.clear());
}
_ => {}
}
}
/// The block names an object refers to.
fn block_refs(k: &EntityKind) -> Vec<String> {
match k {
EntityKind::Insert(i) => vec![i.block.clone()],
EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(),
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
_ => Vec::new(),
}
}
/// Call `f` on each block name an object refers to.
fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) {
match k {
EntityKind::Insert(i) => f(&mut i.block),
EntityKind::Dimension(dm) => {
if let Some(n) = dm.block.as_mut() {
f(n);
}
}
EntityKind::MLeader(m) => {
if let Some(b) = m.block.as_mut() {
f(&mut b.block);
}
}
_ => {}
}
}
/// Point the block references of an object at the names its blocks were copied under.
fn rename_refs(k: &mut EntityKind, renames: &Renames) {
each_ref(k, |n| {
if let Some(new) = renames.get(&n.to_ascii_uppercase()) {
n.clone_from(new);
}
});
}
/// Recolored copies of blocks and styles made so far, and whether a block was left in its own
/// colors because the nesting or copy limit was reached.
#[derive(Default)]
struct Tinted {
/// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the
/// copy's name.
names: HashMap<(&'static str, &'static str, String), String>,
uncolored: bool,
/// Objects copied into block definitions so far, counted against [`MAX_COPIED`].
copied: usize,
next: NextFree,
/// The drawing's block names in upper case, filled on the first block copy. Only
/// [`tinted_block`] adds blocks after that, and it adds their names here too.
blocks: Option<HashSet<String>>,
}
/// The next index to try for each name base. Names are only added while a snapshot is built,
/// so an index found taken stays taken, and each search starts where the last one for the same
/// base stopped: a name costs O(1) amortised instead of a scan from 1.
#[derive(Default)]
struct NextFree(HashMap<(&'static str, String), usize>);
impl NextFree {
/// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices
/// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are,
/// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name.
fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String {
let key = (kind, base.to_ascii_uppercase());
let start = self.0.get(&key).copied().unwrap_or(1);
let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start));
self.0.insert(key, i.saturating_add(1));
name(i)
}
}
/// `base` for 1, `base_2`, `base_3`… after it.
fn suffixed(base: &str, i: usize) -> String {
if i == 1 { base.to_string() } else { format!("{base}_{i}") }
}
/// The word a comparison color adds to the names of its recolored copies.
fn tag(color: Color) -> &'static str {
if color == THIS {
"CURRENT"
} else if color == OTHER {
"COMPARED"
} else {
"COMMON"
}
}
/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are.
fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String {
(1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2))
}
/// Give an object its comparison color: its own color, its attributes' colors, and, through
/// recolored copies of the blocks and styles it refers to, the color of everything they draw.
fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) {
e.common.color = color;
let attribs = match &mut e.kind {
EntityKind::Insert(i) => Some(&mut i.attribs),
EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs),
_ => None,
};
for a in attribs.into_iter().flatten() {
a.props.color = Some(color);
if let Some(ml) = a.multiline.as_mut() {
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
}
}
// Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes
// too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element
// colors, the style colors of a table or a tolerance (through a recolored copy of the style)
// and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out.
match &mut e.kind {
EntityKind::MText(t) => t.contents = without_color_codes(&t.contents),
EntityKind::AttDef(a) => {
if let Some(ml) = a.multiline.as_mut() {
ml.mtext.contents = without_color_codes(&ml.mtext.contents);
}
}
EntityKind::MLeader(m) => {
if let Some(t) = m.text.as_mut() {
t.contents = without_color_codes(&t.contents);
}
}
EntityKind::Dimension(dm) => {
if let Ok(by_block) = serde_json::to_value(Color::ByBlock) {
for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] {
dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var));
dm.overrides.insert(var.into(), by_block.clone());
}
}
}
EntityKind::Tolerance(t) => {
if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) {
t.style = style;
}
}
EntityKind::Hatch(h) => {
if let Some(g) = h.gradient.as_mut() {
(g.color1, g.color2) = (color, color);
}
h.background = None;
}
EntityKind::MLine(m) => {
for el in &mut m.def.elements {
el.color = Color::ByBlock;
}
m.def.fill = None;
}
EntityKind::Table(t) => {
for cell in t.cells.iter_mut().flatten() {
cell.text = without_color_codes(&cell.text);
}
if let Some(style) = tinted_table_style(d, &t.style, color, tinted) {
t.style = style;
}
}
_ => {}
}
if let EntityKind::Dimension(dm) = &mut e.kind {
// A dimension whose block can't be copied (past the limits) is drawn again from its
// geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors.
dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth));
return;
}
each_ref(&mut e.kind, |n| {
if let Some(copy) = tinted_block(d, n, color, tinted, depth) {
*n = copy;
}
});
}
/// The name of a copy of dimension style `name` that draws lines and text in the object's
/// color, made on first use. `None` when the drawing has no such style.
fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
let key = (tag(color), "dimension style", name.to_ascii_uppercase());
if let Some(n) = tinted.names.get(&key) {
return Some(n.clone());
}
let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
let base = format!("COMPARE_{}_{}", tag(color), st.name);
let new = tinted.next.find(
"dimension style",
&base,
d.dim_styles.len(),
|i| suffixed(&base, i),
|n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
);
d.dim_styles.push(DimStyle {
name: new.clone(),
dim_line_color: Color::ByBlock,
ext_line_color: Color::ByBlock,
text_color: Color::ByBlock,
..st
});
tinted.names.insert(key, new.clone());
Some(new)
}
/// The name of a copy of table style `name` whose cell styles draw text and borders in the
/// table's color and fill no cells, made on first use. `None` when the drawing has no such style.
fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option<String> {
let key = (tag(color), "table style", name.to_ascii_uppercase());
if let Some(n) = tinted.names.get(&key) {
return Some(n.clone());
}
let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone();
let base = format!("COMPARE_{}_{}", tag(color), st.name);
let new = tinted.next.find(
"table style",
&base,
d.table_styles.len(),
|i| suffixed(&base, i),
|n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)),
);
let names =
[CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone()));
let mut cell_styles: Vec<CellStyle> = Vec::new();
for n in names {
if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) {
continue;
}
let mut cs = st.cell_style(&n);
cs.name = n;
cs.text_color = Color::ByBlock;
cs.fill = None;
let b = &mut cs.borders;
for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] {
border.color = Color::ByBlock;
}
cell_styles.push(cs);
}
d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st });
tinted.names.insert(key, new.clone());
Some(new)
}
/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the
/// object's color. An escaped backslash (`\\`) stays.
fn without_color_codes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c != '\\' {
out.push(c);
continue;
}
match it.peek().copied() {
Some('C' | 'c') => {
for d in it.by_ref() {
if d == ';' {
break;
}
}
}
Some(n) => {
out.push('\\');
out.push(n);
it.next();
}
None => out.push('\\'),
}
}
out
}
/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None`
/// (the reference keeps the original block) for an xref, a missing block, or past the nesting
/// and copy limits.
fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option<String> {
let key = (tag(color), "block", name.to_ascii_uppercase());
if let Some(n) = tinted.names.get(&key) {
return Some(n.clone());
}
let b = d.block(name)?.clone();
if b.xref_path.is_some() {
return None;
}
if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
tinted.uncolored = true;
return None;
}
tinted.copied += b.entities.len();
// A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of
// drawing the dimension again.
let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D"));
let Tinted { next, blocks, .. } = tinted;
let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
let new = if dim_block {
next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken)
} else {
let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*'));
next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken)
};
taken.insert(new.to_ascii_uppercase());
tinted.names.insert(key, new.clone());
// Hold the name while the nested copies pick theirs.
d.blocks.insert(new.clone(), Arc::new(Block::new(&new)));
let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() };
for e in b.entities.iter() {
let h = d.new_handle();
let mut e = Entity { handle: h, ..(**e).clone() };
detach(&mut e.kind);
paint(d, &mut e, color, tinted, depth + 1);
copy.entities.push(e);
}
d.blocks.insert(new.clone(), Arc::new(copy));
Some(new)
}
/// The name each block copied from the other drawing has in the snapshot, by its upper-case
/// name there.
type Renames = HashMap<String, String>;
/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks.
/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the
/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to);
/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block
/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same
/// prefix. Copied
/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true
/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were
/// left out.
fn copy_blocks<'a>(
d: &mut Drawing,
other: &Drawing,
objs: impl Iterator<Item = &'a Entity>,
folder: Option<&Path>,
tinted: &mut Tinted,
) -> (Renames, bool) {
let mut frontier: Vec<String> = objs.flat_map(|e| block_refs(&e.kind)).collect();
let mut seen = HashSet::new();
let mut needed: Vec<Arc<Block>> = Vec::new();
let mut left_out = false;
// `d`'s relative paths were made absolute from its own folder before the copy.
let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder));
// Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit
// the signatures no longer cover every object, so the blocks count as different.
let mut same = |n: &str, b: &Block| {
!b.anonymous
&& d.block(n).is_some()
&& mine.block(n, 0) == theirs.block(n, 0)
&& mine.work < super::MAX_BLOCK_WORK
&& theirs.work < super::MAX_BLOCK_WORK
};
while let Some(n) = frontier.pop() {
let key = n.to_ascii_uppercase();
if seen.contains(&key) {
continue;
}
if seen.len() >= MAX_BLOCKS {
left_out |= other.block(&n).is_some_and(|b| !same(&n, b));
continue;
}
seen.insert(key);
let Some(b) = other.block(&n) else { continue };
if same(&n, b) {
continue;
}
if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED {
left_out = true;
continue;
}
tinted.copied += b.entities.len();
frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind)));
needed.push(b.clone());
}
// Names `d` does not use stay; the others get free names, avoiding the kept ones.
let mut renames = Renames::new();
let mut taken: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
let mut next = NextFree::default();
for b in needed.iter().filter(|b| d.block(&b.name).is_none()) {
taken.insert(b.name.to_ascii_uppercase());
renames.insert(b.name.to_ascii_uppercase(), b.name.clone());
}
for b in needed.iter().filter(|b| d.block(&b.name).is_some()) {
let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase());
let free = if b.anonymous {
let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect();
next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken)
} else {
next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken)
};
taken.insert(free.to_ascii_uppercase());
renames.insert(b.name.to_ascii_uppercase(), free);
}
for b in needed {
let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue };
let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() };
for e in b.entities.iter() {
let h = d.new_handle();
let mut e = Entity { handle: h, ..(**e).clone() };
detach(&mut e.kind);
rename_refs(&mut e.kind, &renames);
copy.entities.push(e);
}
rebase_block(&mut copy, folder);
d.blocks.insert(name, Arc::new(copy));
}
(renames, left_out)
}
+23 -4
View File
@@ -21,7 +21,7 @@ pub use pick::{HatchPicks, PickMode};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\" (built in, or defined by patternFile | patternText), patternFile?: path to a .pat file | patternText?: its contents (loads the patterns it defines), scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
@@ -34,12 +34,17 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
(ext.width() + ext.height()).max(1e-9) / 20000.0
}
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
/// with the handle of the object it came from.
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
let ext = d.extents(space);
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
let tol = outline_tol(ext);
let mut out = Vec::new();
let mut owners = Vec::new();
for e in store.iter() {
@@ -113,7 +118,8 @@ fn closed_loop(d: &Drawing, e: &Entity) -> Option<Vec<PolyVertex>> {
}
/// Open curves that can make up an island together (a square drawn as four LINEs, two ARCs
/// forming a circle). Closed objects are islands on their own (see `closed_loop`).
/// forming a circle, the inner lines of multiline walls around a room). Closed objects are
/// islands on their own (see `closed_loop`).
fn island_curve(d: &Drawing, e: &Entity) -> bool {
matches!(
e.kind,
@@ -123,6 +129,7 @@ fn island_curve(d: &Drawing, e: &Entity) -> bool {
| EntityKind::LwPolyline(_)
| EntityKind::Polyline3d(_)
| EntityKind::Spline(_)
| EntityKind::MLine(_)
) && closed_loop(d, e).is_none()
}
@@ -204,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
let ext = d.extents(&space);
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
let (polys, owners) = outlines(d, &space, None);
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
let outer = found.outer;
@@ -458,7 +467,7 @@ fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
&& cadcraft_doc::library::pattern(&up).is_none()
&& patfile::user_pattern(&up).is_none()
{
return Err(bad("hatch", format!("unknown pattern {up}")));
return Err(bad("hatch", format!("unknown pattern {up}: not built in; load it with patternFile or patternText (a .pat file)")));
}
make(s, p, false)
}
@@ -840,6 +849,16 @@ mod tests {
assert!(s.current_prompt().unwrap().picker.is_none());
}
#[test]
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "GRAVEL" })).unwrap_err();
assert!(e.to_string().contains("patternFile"), "{e}");
let spec = specs().into_iter().find(|c| c.id == "hatch").unwrap();
assert!(spec.params.contains("patternFile") && spec.params.contains("patternText"), "{}", spec.params);
}
#[test]
fn json_hatch_refuses_an_undefined_pattern() {
let mut s = Session::new();
+3 -2
View File
@@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
if nb.is_empty() {
return Refill::Open;
}
let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0;
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
let tol = super::outline_tol(nb);
let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9);
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
let polys = region::snap_ends(&polys, tol, snap);
let gap = gap_tolerance(after);
let found = if l.outer {
// The face around a point inside the old loop, carried along with the objects.
+265 -9
View File
@@ -1,5 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
@@ -8,9 +10,13 @@ use super::*;
use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod loops;
mod regions;
mod shapes;
mod usage;
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and
/// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist)
@@ -45,6 +51,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
.noundo(),
@@ -64,15 +72,119 @@ pub fn specs() -> Vec<CommandSpec> {
v
}
/// Format a distance with the drawing's `LUNITS` and `LUPREC` (decimal units, four places by
/// default).
fn fmt_d(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, h.0, h.1)
}
/// Format an angle with the drawing's `AUNITS` and `AUPREC` (decimal degrees, whole degrees by
/// default).
fn fmt_a(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
format_angle(v, h.0, h.1)
}
/// Schema of the analysis self-check block (#1440). Fields may be added within a version; a
/// rename or a changed meaning bumps it.
pub(super) const SELF_CHECK_SCHEMA: u64 = 1;
/// How far past its bound a value may be before it is implausible: a relative slack for the
/// rounding of two ways of measuring the same thing.
const IMPLAUSIBLE_TOLERANCE: f64 = 1e-9;
/// How many objects of one type an analysis skipped, and every distinct reason it gave.
#[derive(Default)]
struct SkipTally {
count: u64,
reasons: BTreeSet<String>,
}
/// The self-check every analysis result carries (#1440): how many objects it found, how many it
/// analysed, what it skipped and why, the caps it hit with their values, and values so
/// implausible that they point at a bug rather than at the drawing.
#[derive(Default)]
pub(super) struct SelfCheck {
found: u64,
analyzed: u64,
skipped: BTreeMap<String, SkipTally>,
caps: BTreeMap<String, u64>,
implausible: Vec<Value>,
}
impl SelfCheck {
/// An empty self-check: nothing found, analysed or skipped yet.
pub(super) fn new() -> Self {
Self::default()
}
/// Count objects the analysis found (matched its filters).
pub(super) fn found(&mut self, n: u64) {
self.found = self.found.saturating_add(n);
}
/// Count objects the analysis actually analysed.
pub(super) fn analyzed(&mut self, n: u64) {
self.analyzed = self.analyzed.saturating_add(n);
}
/// Count `n` objects of `ty` skipped, with the reason.
pub(super) fn skip(&mut self, ty: &str, reason: &str, n: u64) {
let skip = self.skipped.entry(ty.to_string()).or_default();
skip.count = skip.count.saturating_add(n);
skip.reasons.insert(reason.to_string());
}
/// Record a cap the analysis hit, with its value (e.g. `limit`, 200).
pub(super) fn cap(&mut self, name: &str, value: u64) {
self.caps.insert(name.to_string(), value);
}
/// Flag `value` as implausible when it is past `bound`, with the rule that makes it so. A
/// non-finite `bound` means no bound applies (an empty drawing has no extents). Values within
/// `IMPLAUSIBLE_TOLERANCE` of the bound are not flagged: they are the rounding of two ways
/// of measuring the same thing (a border rectangle's shoelace area against the extents'
/// `width * height`), not a bug.
pub(super) fn implausible(&mut self, handle: Handle, ty: &str, field: &str, value: f64, bound: f64, rule: &str) {
if value.is_finite() && bound.is_finite() && value > bound + bound.abs() * IMPLAUSIBLE_TOLERANCE {
self.implausible.push(json!({
"handle": handle.hex(),
"type": ty,
"field": field,
"value": value,
"bound": bound,
"reason": rule,
}));
}
}
/// The skip map in the shape analyses have always reported: type → {count, reason}.
pub(super) fn skipped_json(&self) -> Value {
Value::Object(
self.skipped
.iter()
.map(|(ty, skip)| {
let reason = skip.reasons.iter().map(String::as_str).collect::<Vec<_>>().join("; ");
(ty.clone(), json!({ "count": skip.count, "reason": reason }))
})
.collect(),
)
}
/// The self-check as the JSON object every analysis returns: schema, counts, skip map, caps
/// and implausible values.
pub(super) fn to_json(&self) -> Value {
json!({
"schema": SELF_CHECK_SCHEMA,
"found": self.found,
"analyzed": self.analyzed,
"skipped": self.skipped_json(),
"caps": self.caps,
"implausible": self.implausible,
})
}
}
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
@@ -289,8 +401,17 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
(a, per)
}
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
/// error, never a fall back to the selection.
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
match p.get("points") {
None | Some(Value::Null) => Ok(None),
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
}
}
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
let (a, per) = if let Some(pts) = points_param(p, "points") {
let (a, per) = if let Some(pts) = given_points(p, "area")? {
area_of(&pts)
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
@@ -303,6 +424,37 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
}
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
let a = if let Some(pts) = given_points(p, "volume.measure")? {
if pts.len() < 3 {
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
}
area_of(&pts).0
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
object_area(s, h)?.0
};
let height = f64_req("volume.measure", p, "height")?;
let v = a * height.abs();
if !v.is_finite() {
return Err(bad("volume.measure", "the volume is too large"));
}
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
}
fn volume_text(s: &Session, v: f64) -> String {
format!("Volume = {}", fmt_volume(s, v))
}
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
/// show it as a decimal number, not in feet and inches.
fn fmt_volume(s: &Session, v: f64) -> String {
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
}
/// Area and perimeter of a closed object (AREA's Object option).
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
let d = s.doc()?;
@@ -777,9 +929,19 @@ pub(super) struct AreaM {
object: bool,
/// Asking for the length of a segment (the Length option).
length: bool,
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
volume: bool,
/// Areas measured in volume mode that still wait for their height, oldest first.
pending: Vec<f64>,
/// The first point of a height given by two points.
height_from: Option<Vec2>,
}
impl AreaM {
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
pub(super) fn volume() -> Self {
AreaM { volume: true, ..AreaM::default() }
}
fn mode_prefix(&self) -> &'static str {
match self.mode {
AreaMode::Single => "",
@@ -787,8 +949,49 @@ impl AreaM {
AreaMode::Subtract => "(SUBTRACT mode) ",
}
}
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
/// "Subtract volume".
fn mode_keywords(&self) -> (&'static str, &'static str) {
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
}
/// The running total with `v` added or subtracted; in single mode, the total as it is.
fn total_with(&self, v: f64) -> f64 {
match self.mode {
AreaMode::Single => self.total,
AreaMode::Add => self.total + v,
AreaMode::Subtract => self.total - v,
}
}
/// Measure the oldest pending area's volume with height `h`.
fn height(&mut self, s: &mut Session, h: f64) -> Step {
match self.pending.first().map(|a| a * h.abs()) {
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
self.pending.remove(0);
self.volume_measured(s, v)
}
_ => {
s.echo("The volume is too large.");
Step::Continue
}
}
}
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
s.echo(volume_text(s, v));
if self.mode == AreaMode::Single {
return Step::Done;
}
self.total = self.total_with(v);
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
Step::Continue
}
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
/// In volume mode the area waits for its height instead.
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
if self.volume {
self.pending.push(a);
return Step::Continue;
}
s.echo(area_text(s, a, per));
match self.mode {
AreaMode::Single => return Step::Done,
@@ -811,9 +1014,15 @@ impl AreaM {
impl Interactive for AreaM {
fn name(&self) -> &'static str {
"AREA"
if self.volume { "MEASUREGEOM" } else { "AREA" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.pending.is_empty() {
return match self.height_from {
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER),
};
}
if self.object {
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
}
@@ -821,17 +1030,35 @@ impl Interactive for AreaM {
return length_prompt();
}
let next = format!("{}Specify next point", self.mode_prefix());
let (add, subtract) = self.mode_keywords();
match (self.pts.len(), self.mode) {
(0, AreaMode::Single) => {
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
}
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.pending.is_empty() {
// A height is typed, or given by two points (their distance).
match (i, self.height_from) {
(Input::Point(p), None) => self.height_from = Some(p),
(Input::Point(p), Some(from)) => {
self.height_from = None;
return Ok(self.height(s, p.dist(from)));
}
(Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
Some(h) => return Ok(self.height(s, h)),
None => s.echo("Requires a distance."),
},
(Input::Enter, None) => s.echo("Requires a height."),
(Input::Enter, Some(_)) => s.echo("Requires a second point."),
(Input::Text(_), Some(_)) => s.echo("Requires a point."),
_ => {}
}
return Ok(Step::Continue);
}
if self.object {
match i {
Input::Pick(hs) => {
@@ -841,6 +1068,10 @@ impl Interactive for AreaM {
if self.measured(s, a, per) == Step::Done {
return Ok(Step::Done);
}
// A single volume takes the first object with an area.
if self.volume && self.mode == AreaMode::Single {
break;
}
}
Err(_) => s.echo("The selected object has no area."),
}
@@ -868,8 +1099,8 @@ impl Interactive for AreaM {
Input::Point(p) => self.pts.push(p),
Input::Keyword(k) => match k.as_str() {
"Object" => self.object = true,
"Add area" => self.mode = AreaMode::Add,
"Subtract area" => self.mode = AreaMode::Subtract,
"Add area" | "Add volume" => self.mode = AreaMode::Add,
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
"Undo" => {
self.pts.pop();
}
@@ -896,3 +1127,28 @@ impl Interactive for AreaM {
vec![super::helpers::lwpoly(v, true)]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
/// the extents' `width * height`) is rounding, not a bug; a value clearly past it still is.
#[test]
fn implausible_allows_rounding_but_not_a_real_excess() {
let bound: f64 = 100.0;
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound.next_up(), bound, "past the bound");
assert!(check.implausible.is_empty(), "one ulp past the bound is rounding: {}", check.to_json());
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound * (1.0 + 1e-6), bound, "past the bound");
assert_eq!(check.implausible.len(), 1, "a real excess is still flagged: {}", check.to_json());
// A bound that is not finite means no bound applies: the value is not flagged.
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Region", "area", 1e9, f64::INFINITY, "past the bound");
assert!(check.implausible.is_empty(), "an infinite bound applies to nothing: {}", check.to_json());
}
}
+114 -19
View File
@@ -10,6 +10,7 @@ use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::super::*;
use super::SelfCheck;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Merges done while expanding nested references; a hostile file stops here (and says so).
@@ -20,12 +21,15 @@ const MAX_HANDLES: usize = 100_000;
/// COUNT's JSON parameters.
pub(super) const PARAMS: &str = "{block?, object?: handle (count objects like this one), space?: current|model|all|<layout> (default current), \
handles?: [...] (count in these objects), nested?: bool (default true), highlight?: bool} → \
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message`";
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message` and \
`selfCheck` (found/analyzed/skipped counts with reasons, and the caps hit: work, handles, blockDepth)";
/// COUNT's interactive command-line prompt machine.
pub(super) fn machine() -> Box<dyn Interactive> {
Box::new(CountM)
}
/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point).
pub(super) fn table_machine() -> Box<dyn Interactive> {
Box::new(CountTableM)
}
@@ -109,11 +113,14 @@ struct Expander<'a> {
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
work: u64,
truncated: bool,
/// A block at the end of the depth cap still held references.
depth_capped: bool,
}
impl<'a> Expander<'a> {
/// An expander that has not traversed any block yet.
fn new(d: &'a Drawing) -> Self {
Expander { d, memo: HashMap::new(), work: 0, truncated: false }
Expander { d, memo: HashMap::new(), work: 0, truncated: false, depth_capped: false }
}
/// The definition's own name for a referenced block (names are case-insensitive).
fn canonical(&self, name: &str) -> String {
@@ -125,10 +132,17 @@ impl<'a> Expander<'a> {
if let Some(m) = self.memo.get(&key) {
return m.clone();
}
if levels == 0 {
// More references than MAX_BLOCK_DEPTH covers: say so rather than silently stop.
if self.d.block(name).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(_)))) {
self.depth_capped = true;
}
let out = Rc::new(BTreeMap::new());
self.memo.insert(key, out.clone());
return out;
}
let mut out: BTreeMap<String, u64> = BTreeMap::new();
if levels > 0
&& let Some(b) = self.d.block(name)
{
if let Some(b) = self.d.block(name) {
let b = b.clone();
for e in b.entities.iter() {
let EntityKind::Insert(i) = &e.kind else { continue };
@@ -173,6 +187,14 @@ struct Tally {
/// Top-level objects holding `want` (when one block is asked for).
handles: Vec<Handle>,
truncated: bool,
/// Top-level objects scanned, and the block references among them.
found: u64,
analyzed: u64,
/// Top-level objects that are not block references, by type.
skipped: BTreeMap<&'static str, u64>,
/// More top-level objects held the block than `MAX_HANDLES`.
handles_capped: bool,
depth_capped: bool,
}
/// Count block references in `scope`; with `want`, also collect the top-level objects holding it.
@@ -180,7 +202,13 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
let mut x = Expander::new(d);
let mut t = Tally::default();
for (space, e) in scope.objects(d) {
let EntityKind::Insert(i) = &e.kind else { continue };
t.found = t.found.saturating_add(1);
let EntityKind::Insert(i) = &e.kind else {
let n = t.skipped.entry(e.kind.type_name()).or_default();
*n = n.saturating_add(1);
continue;
};
t.analyzed = t.analyzed.saturating_add(1);
let name = x.canonical(&i.block);
let m = copies(i);
let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name));
@@ -199,15 +227,41 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k));
}
}
if holds && t.handles.len() < MAX_HANDLES {
t.handles.push(e.handle);
if holds {
if t.handles.len() < MAX_HANDLES {
t.handles.push(e.handle);
} else {
t.handles_capped = true;
}
}
}
t.spaces.retain(|_, m| !m.is_empty());
t.truncated = x.truncated;
t.depth_capped = x.depth_capped;
t
}
/// The self-check for a COUNT-style result: what was scanned, counted and skipped, and the caps
/// that stopped it.
fn self_check(t: &Tally) -> Value {
let mut check = SelfCheck::new();
check.found(t.found);
check.analyzed(t.analyzed);
for (ty, n) in &t.skipped {
check.skip(ty, "not a block reference", *n);
}
if t.truncated {
check.cap("work", MAX_WORK);
}
if t.handles_capped {
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
}
if t.depth_capped {
check.cap("blockDepth", u64::try_from(MAX_BLOCK_DEPTH).unwrap_or(u64::MAX));
}
check.to_json()
}
/// A translation-free signature of an object's geometry: objects with equal keys are copies of
/// each other (moved, not rotated or scaled). Coordinates are rounded to 1e-6.
fn shape_key(k: &EntityKind) -> Option<String> {
@@ -262,7 +316,15 @@ fn list(t: &Tally, scope: &Scope) -> Value {
m
};
let total = t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b));
json!({ "blocks": t.blocks, "nested": t.nested, "spaces": t.spaces, "total": total, "truncated": t.truncated, "message": message })
json!({
"blocks": t.blocks,
"nested": t.nested,
"spaces": t.spaces,
"total": total,
"truncated": t.truncated,
"selfCheck": self_check(t),
"message": message,
})
}
/// Count block `b` (its references, nested ones included) in `scope`.
@@ -273,7 +335,15 @@ fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value {
let k = t.nested.get(&name).copied().unwrap_or(0);
let wh = scope.label();
let message = if k > 0 { format!("Block {name}: {n} in {wh} ({k} nested).") } else { format!("Block {name}: {n} in {wh}.") };
json!({ "block": name, "count": n, "nested": k, "handles": hex(&t.handles), "truncated": t.truncated, "message": message })
json!({
"block": name,
"count": n,
"nested": k,
"handles": hex(&t.handles),
"truncated": t.truncated,
"selfCheck": self_check(&t),
"message": message,
})
}
/// Count the objects in `scope` shaped like `h` (a block reference: references of its block).
@@ -284,18 +354,35 @@ fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result<Val
}
let ty = e.kind.type_name();
let key = shape_key(&e.kind).ok_or_else(|| bad("count", format!("a {ty} can't be counted by shape")))?;
let hs: Vec<Handle> = scope
.objects(d)
.into_iter()
.filter(|(_, o)| o.kind.type_name() == ty && shape_key(&o.kind).as_deref() == Some(key.as_str()))
.map(|(_, o)| o.handle)
.take(MAX_HANDLES)
.collect();
let mut check = SelfCheck::new();
let mut hs: Vec<Handle> = Vec::new();
let mut capped = false;
for (_, o) in scope.objects(d) {
check.found(1);
if o.kind.type_name() != ty {
check.skip(o.kind.type_name(), "a different type", 1);
continue;
}
check.analyzed(1);
if shape_key(&o.kind).as_deref() == Some(key.as_str()) {
if hs.len() < MAX_HANDLES {
hs.push(o.handle);
} else {
capped = true;
}
}
}
if capped {
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
}
let n = hs.len();
let message = format!("{ty}: {n} like the selected one in {}.", scope.label());
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "message": message }))
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "selfCheck": check.to_json(), "message": message }))
}
/// The body of COUNT and COUNTLIST: list the blocks in scope, count one block, or count the
/// objects shaped like a picked one. `default_all` makes an omitted `space` mean every space
/// (COUNTLIST) instead of the current one (COUNT).
fn run(s: &mut Session, cmd: &str, p: &Value, default_all: bool) -> Result<Value> {
let scope = Scope::parse(s, cmd, p, default_all)?;
let nested = bool_or(p, "nested", true);
@@ -336,6 +423,8 @@ fn table_style(s: &Session) -> cadcraft_doc::TableStyle {
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
}
/// COUNTTABLE: insert a table of the block counts in scope at `at`, and report what the tally
/// covered (and refused: no block references in scope is an error, not an empty table).
pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "counttable";
let at = point_req(CMD, p, "at")?;
@@ -348,9 +437,15 @@ pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
let header = Some(vec!["Name".to_string(), "Count".to_string()]);
let table = super::super::table::make_table(&table_style(s), at, cells.len(), 2, None, None, Some("Block Count"), header, &cells);
let h = s.add_entity(EntityKind::Table(table))?;
Ok(json!({ "handle": h.hex(), "rows": cells.len(), "message": format!("Table of {} block(s) inserted.", cells.len()) }))
Ok(json!({
"handle": h.hex(),
"rows": cells.len(),
"selfCheck": self_check(&t),
"message": format!("Table of {} block(s) inserted.", cells.len()),
}))
}
/// Echo a result's `message` at the command line, one line at a time.
fn echo_lines(s: &mut Session, r: &Value) {
if let Some(m) = r.get("message").and_then(Value::as_str) {
for l in m.lines() {
+439
View File
@@ -0,0 +1,439 @@
//! LOOPS: implied closed loops from loose LINE and ARC chains — perimeter, area and winding for
//! each closed loop, the gap and its location for each open chain, and branch junctions (three or
//! more ends meeting) reported but not resolved. Read-only.
//!
//! Boundaries are often drawn as separate LINEs and ARCs rather than one polyline, so an agent
//! cannot tell whether they form a closed loop or by how much a "closed" outline is open. The
//! endpoint chaining is quadratic in the number of endpoints; past `maxPairs` it reports `capped`
//! and analyzes nothing rather than guess from a partial pairing.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Arc as GeoArc, EPS, Vec2};
use serde_json::{Value, json};
use super::super::*;
use crate::{Result, Session};
const CMD: &str = "loops";
/// Endpoint pairs compared before the quadratic chaining gives up (reported as `capped`).
const DEFAULT_PAIR_LIMIT: usize = 1_000_000;
/// The most pairs a caller may ask for: a bounded amount of work however large the drawing.
const MAX_PAIR_LIMIT: usize = 100_000_000;
/// Loops returned, and open chains and junctions kept, by default.
const DEFAULT_LIMIT: usize = 200;
/// The smallest default tolerance, for a drawing at the origin.
const MIN_TOLERANCE: f64 = 1e-12;
pub const PARAMS: &str = "{tolerance?: positive number (default 1e-9 × the extents diagonal), maxPairs? (endpoint pairs compared, default 1000000, at most 100000000), limit? (default 200), offset?} → \
{schema: 1, tolerance, pairTests, pairTestsNeeded, pairLimit, capped, count (all closed loops), returned, offset, truncated, \
loops: [{members: [handle], perimeter, area, winding: ccw|cw}], \
openChains: {count, truncated, chains: [{members, gap, at: [x, y], ends: [[x, y], [x, y]], length}]}, \
junctions: {count, truncated, junctions: [{at: [x, y], ends, members}]}, skipped: {<type>: {count, reason}}}: \
chains LINE and ARC endpoints that meet within the tolerance; junctions (three or more ends meeting) are reported, not resolved";
/// Why a LINE or ARC cannot take part in a chain.
type Skipped = BTreeMap<&'static str, (usize, BTreeSet<&'static str>)>;
/// One chainable entity, oriented as drawn (a line a→b, an arc CCW start→end).
struct Piece {
handle: Handle,
start: Vec2,
end: Vec2,
/// The arc, when the piece is one.
arc: Option<GeoArc>,
}
impl Piece {
fn at(&self, which: usize) -> Vec2 {
if which == 0 { self.start } else { self.end }
}
fn length(&self) -> f64 {
match self.arc {
Some(a) => a.radius * a.sweep(),
None => self.start.dist(self.end),
}
}
}
/// An entity endpoint in the chain graph. Each piece has one at index `2 * piece` and one at
/// `2 * piece + 1`.
#[derive(Clone, Copy)]
struct End {
piece: usize,
at: Vec2,
}
/// A piece traversed one way round a chain.
struct Traversed {
start: Vec2,
end: Vec2,
/// The arc and whether the traversal follows its CCW direction.
arc: Option<(GeoArc, bool)>,
}
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let limit = count("limit", DEFAULT_LIMIT)?;
let offset = count("offset", 0)?;
let pair_limit = count("maxPairs", DEFAULT_PAIR_LIMIT)?.min(MAX_PAIR_LIMIT);
let tolerance = given("tolerance")
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
.transpose()?;
let s: &Session = s;
let st = s.state()?;
let d = &st.doc;
let tol = tolerance.unwrap_or_else(|| {
let ext = d.extents(&st.space);
if ext.is_empty() {
return MIN_TOLERANCE;
}
let diag = ext.min.dist(ext.max);
if diag.is_finite() { (diag * 1e-9).max(MIN_TOLERANCE) } else { MIN_TOLERANCE }
});
let mut skipped: Skipped = BTreeMap::new();
let Some(store) = d.space(&st.space) else {
return Ok(empty_report(offset, tol, 0, 0, pair_limit, false, skipped));
};
let pieces: Vec<Piece> = store.iter().filter_map(|e| chainable(e.handle, &e.kind, &mut skipped)).collect();
let mut ends: Vec<End> = Vec::with_capacity(pieces.len().saturating_mul(2));
for (i, piece) in pieces.iter().enumerate() {
ends.push(End { piece: i, at: piece.start });
ends.push(End { piece: i, at: piece.end });
}
let n = ends.len();
let needed = n.saturating_mul(n.saturating_sub(1)) / 2;
if needed > pair_limit {
return Ok(empty_report(offset, tol, 0, needed, pair_limit, true, skipped));
}
// Endpoints closer than the tolerance meet. Transitive components of three or more ends are
// branch junctions; a component of exactly two is a simple joint.
let mut parent: Vec<usize> = (0..n).collect();
for i in 0..n {
let Some(a) = ends.get(i) else { continue };
for j in (i + 1)..n {
let Some(b) = ends.get(j) else { continue };
if a.at.dist(b.at) <= tol {
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
if ra != rb
&& let Some(slot) = parent.get_mut(ra)
{
*slot = rb;
}
}
}
}
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..n {
let root = find(&mut parent, i);
groups.entry(root).or_default().push(i);
}
// Junctions are reported and their pieces left out of the chains: not resolved.
let mut excluded = vec![false; pieces.len()];
let mut junctions: Vec<Value> = Vec::new();
let mut joints: Vec<(usize, usize)> = Vec::new();
for group in groups.values() {
match group.as_slice() {
&[a, b] => joints.push((a, b)),
[_, _, ..] => {
let scale = group.len() as f64;
let mut at = Vec2::ZERO;
let mut members: Vec<Handle> = Vec::new();
for &e in group {
let Some(end) = ends.get(e) else { continue };
at += end.at / scale;
let Some(piece) = pieces.get(end.piece) else { continue };
if !members.contains(&piece.handle) {
members.push(piece.handle);
}
if let Some(slot) = excluded.get_mut(end.piece) {
*slot = true;
}
}
junctions.push(json!({
"at": [at.x, at.y],
"ends": group.len(),
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
}));
}
_ => {}
}
}
// A joint links the two ends for the walk. A joint into an excluded piece is not a link, and
// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
let mut link: Vec<Option<usize>> = vec![None; n];
for (a, b) in joints {
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
continue;
}
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = link.get_mut(a) {
*slot = Some(b);
}
if let Some(slot) = link.get_mut(b) {
*slot = Some(a);
}
}
let mut visited = vec![false; pieces.len()];
let mut loops: Vec<Value> = Vec::new();
let mut open: Vec<Value> = Vec::new();
// Paths first: a free end marks a chain. What is left with both ends linked is a cycle.
open_chains(&pieces, &excluded, &link, &mut visited, &mut open);
closed_loops(&pieces, &excluded, &link, &mut visited, &mut loops);
let total = loops.len();
let page: Vec<Value> = loops.into_iter().skip(offset).take(limit).collect();
let returned = page.len();
let truncated = returned < total.saturating_sub(offset);
let open_total = open.len();
let open_page: Vec<Value> = open.into_iter().take(limit).collect();
let open_truncated = open_page.len() < open_total;
let junction_total = junctions.len();
let junction_page: Vec<Value> = junctions.into_iter().take(limit).collect();
let junction_truncated = junction_page.len() < junction_total;
Ok(json!({
"schema": 1,
"tolerance": tol,
"pairTests": needed,
"pairTestsNeeded": needed,
"pairLimit": pair_limit,
"capped": false,
"count": total,
"returned": returned,
"offset": offset,
"truncated": truncated,
"loops": page,
"openChains": { "count": open_total, "truncated": open_truncated, "chains": open_page },
"junctions": { "count": junction_total, "truncated": junction_truncated, "junctions": junction_page },
"skipped": skipped_json(skipped),
}))
}
/// A report with nothing analyzed: an empty drawing, or one past the pair cap.
fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value {
json!({
"schema": 1,
"tolerance": tol,
"pairTests": pair_tests,
"pairTestsNeeded": pair_tests_needed,
"pairLimit": pair_limit,
"capped": capped,
"count": 0,
"returned": 0,
"offset": offset,
"truncated": false,
"loops": [],
"openChains": { "count": 0, "truncated": false, "chains": [] },
"junctions": { "count": 0, "truncated": false, "junctions": [] },
"skipped": skipped_json(skipped),
})
}
fn skipped_json(skipped: Skipped) -> Value {
let m: BTreeMap<&str, Value> =
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
json!(m)
}
fn skip(skipped: &mut Skipped, ty: &'static str, why: &'static str) {
let entry = skipped.entry(ty).or_default();
entry.0 += 1;
entry.1.insert(why);
}
/// The piece for a LINE or ARC, or `None` when the entity is not chainable.
fn chainable(handle: Handle, kind: &EntityKind, skipped: &mut Skipped) -> Option<Piece> {
match kind {
EntityKind::Line(l) => {
let (a, b) = (l.a.xy(), l.b.xy());
if !a.is_finite() || !b.is_finite() || a.dist(b) <= EPS {
skip(skipped, "Line", "zero length");
return None;
}
Some(Piece { handle, start: a, end: b, arc: None })
}
EntityKind::Arc(a) => {
let (c, r) = (a.center.xy(), a.radius.abs());
if !c.is_finite() || !a.radius.is_finite() || r <= EPS || !a.start.is_finite() || !a.end.is_finite() {
skip(skipped, "Arc", "degenerate");
return None;
}
let arc = GeoArc::new(c, r, a.start, a.end);
let (start, end) = (arc.start_point(), arc.end_point());
if !start.is_finite() || !end.is_finite() {
skip(skipped, "Arc", "degenerate");
return None;
}
Some(Piece { handle, start, end, arc: Some(arc) })
}
_ => None,
}
}
fn find(parent: &mut [usize], mut x: usize) -> usize {
while let Some(&p) = parent.get(x) {
if p == x {
return x;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(x) {
*slot = gp;
}
x = gp;
}
x
}
/// Walk the cycles left after the open paths from a free end, and report their geometry.
fn closed_loops(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
for i in 0..pieces.len() {
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
continue;
}
// Only a piece linked at both ends can be in a cycle.
if link.get(i * 2).copied().flatten().is_none() || link.get(i * 2 + 1).copied().flatten().is_none() {
continue;
}
let mut members: Vec<usize> = Vec::new();
let mut forward: Vec<bool> = Vec::new();
let (mut cur, mut entry) = (i, 0usize);
let mut closed = false;
loop {
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
break;
}
if let Some(slot) = visited.get_mut(cur) {
*slot = true;
}
members.push(cur);
forward.push(entry == 0);
let exit = 1 - entry;
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
let (next, which) = (q / 2, q % 2);
if next == i && which == 0 {
closed = true;
break;
}
if visited.get(next).copied().unwrap_or(true) {
break;
}
cur = next;
entry = which;
}
if !closed || members.is_empty() {
continue;
}
let segs = traversed(pieces, &members, &forward);
let (perimeter, area, signed) = facts(&segs);
out.push(json!({
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
"perimeter": perimeter,
"area": area,
"winding": if signed < 0.0 { "cw" } else { "ccw" },
}));
}
}
/// Walk paths from every free end: an open chain, with the gap between its two ends.
fn open_chains(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
for i in 0..pieces.len() {
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
continue;
}
let Some(entry0) = [0usize, 1].into_iter().find(|w| link.get(i * 2 + w).copied().flatten().is_none()) else { continue };
let mut members: Vec<usize> = Vec::new();
let mut forward: Vec<bool> = Vec::new();
let (mut cur, mut entry) = (i, entry0);
let mut exit = entry0;
loop {
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
break;
}
if let Some(slot) = visited.get_mut(cur) {
*slot = true;
}
members.push(cur);
forward.push(entry == 0);
exit = 1 - entry;
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
let (next, which) = (q / 2, q % 2);
if visited.get(next).copied().unwrap_or(true) {
break;
}
cur = next;
entry = which;
}
let (Some(first_piece), Some(last_piece)) = (pieces.get(i), members.last().and_then(|&m| pieces.get(m))) else { continue };
let (first, last) = (first_piece.at(entry0), last_piece.at(exit));
let gap = first.dist(last);
let at = first * 0.5 + last * 0.5;
let length: f64 = members.iter().filter_map(|&m| pieces.get(m)).map(Piece::length).sum();
out.push(json!({
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
"gap": gap,
"at": [at.x, at.y],
"ends": [[first.x, first.y], [last.x, last.y]],
"length": length,
}));
}
}
fn traversed(pieces: &[Piece], members: &[usize], forward: &[bool]) -> Vec<Traversed> {
members
.iter()
.zip(forward)
.filter_map(|(&m, &fwd)| {
let p = pieces.get(m)?;
Some(Traversed { start: if fwd { p.start } else { p.end }, end: if fwd { p.end } else { p.start }, arc: p.arc.map(|a| (a, fwd)) })
})
.collect()
}
/// Perimeter, unsigned area and signed area (CCW positive, as SHAPES reports winding) of a closed
/// chain of lines and arcs. The closing chord between the last end and the first is the chain's gap
/// and counts in the perimeter, as an open polyline with matching ends does in SHAPES.
fn facts(segs: &[Traversed]) -> (f64, f64, f64) {
let Some(first) = segs.first() else { return (0.0, 0.0, 0.0) };
let perimeter: f64 = segs.iter().map(seg_len).sum::<f64>() + segs.last().map_or(0.0, |last| last.end.dist(first.start));
let signed = signed_area(segs, first.start);
(perimeter, signed.abs(), signed)
}
fn seg_len(t: &Traversed) -> f64 {
match t.arc {
Some((a, _)) => a.radius * a.sweep(),
None => t.start.dist(t.end),
}
}
/// Green's theorem over the closed path, measured about its first point so a small loop far from
/// the origin keeps its area. An arc contributes its exact circular integral, not a tessellation.
fn signed_area(segs: &[Traversed], origin: Vec2) -> f64 {
let mut twice = 0.0;
for t in segs {
match t.arc {
None => twice += (t.start - origin).cross(t.end - origin),
Some((a, forward)) => {
let sweep = a.sweep();
let (from, to, d) = if forward { (a.start, a.start + sweep, sweep) } else { (a.end, a.end - sweep, -sweep) };
let c = a.center - origin;
twice += a.radius * a.radius * d + a.radius * (c.x * (to.sin() - from.sin()) - c.y * (to.cos() - from.cos()));
}
}
}
twice / 2.0
}
+27 -2
View File
@@ -12,8 +12,10 @@ use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::super::*;
use super::SelfCheck;
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The region commands: UNION, SUBTRACT and INTERSECT combine regions, MASSPROP measures them.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("union", "Union", run_union)
@@ -33,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
.params("{handles?} (regions) → properties of each")
.params("{handles?} (regions) → properties of each, plus `selfCheck` (found/analyzed/skipped with reasons, caps, implausible values)")
.noundo()
.interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))),
]
@@ -211,13 +213,16 @@ impl Interactive for SubtractM {
fn name(&self) -> &'static str {
"SUBTRACT"
}
/// Tell the user what to select first: the regions to subtract from.
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo("Select regions to subtract from ..");
Ok(Step::Continue)
}
/// Prompt for the current selection phase (regions to subtract from, then regions to remove).
fn prompt(&self, _s: &Session) -> Prompt {
self.sel.prompt()
}
/// The first selection is the regions to subtract from; the second is subtracted from them.
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
@@ -243,18 +248,38 @@ impl Interactive for SubtractM {
}
}
/// MASSPROP: area, perimeter, bounding box, centroid, moments of inertia and principal directions
/// for each selected region, plus a self-check of what was measured, skipped and found
/// implausible.
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let rs = regions(s, &hs)?;
if rs.is_empty() {
return Err(other("Select one or more regions."));
}
let mut check = SelfCheck::new();
let d = s.doc()?;
let extents = d.extents(&s.space());
// No visible extents means no bound to compare an area against (`implausible` skips an
// infinite bound).
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
for h in &hs {
check.found(1);
match d.entity(*h).map(|e| &e.kind) {
Some(EntityKind::Region(_)) => check.analyzed(1),
Some(kind) => check.skip(kind.type_name(), "not a region", 1),
None => check.skip("Missing", "no such object", 1),
}
}
let f = |v: f64| super::fmt_d(s, v);
let mut out = Vec::new();
let mut lines = Vec::new();
for (h, r) in &rs {
let m = r.props();
let c = m.centroid;
let diagonal = if m.bounds.is_empty() { 0.0 } else { m.bounds.min.dist(m.bounds.max) };
check.implausible(*h, "Region", "perimeter", m.perimeter, diagonal * 10.0, "perimeter is more than 10 x the bounding-box diagonal");
check.implausible(*h, "Region", "area", m.area, extents_area, "area is larger than the drawing extents");
// Moments about the centroid, and the principal moments and directions.
let [ixc, iyc, ixyc] = m.centroid_moments;
let mid = (ixc + iyc) / 2.0;
@@ -292,5 +317,5 @@ fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
ang.cos(),
));
}
Ok(json!({ "regions": out, "message": lines.join("\n") }))
Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") }))
}
+98 -27
View File
@@ -2,26 +2,37 @@
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
//! Read-only.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind};
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
use serde_json::{Value, json};
use super::super::*;
use super::{area_of, object_area};
use super::{SelfCheck, area_of, object_area};
use crate::{Result, Session};
const CMD: &str = "shapes";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {<type>: {count, reason}}, caps: {<name>: <value>}, implausible: [{handle, type, field, value, bound, reason}]} (what the analysis covered, the caps it hit, and values that point at a bug)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
enum Facts {
Shape { perimeter: f64, area: f64, signed: f64, centroid: Option<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
Shape {
perimeter: f64,
area: f64,
signed: f64,
centroid: Option<Vec2>,
cross: Option<Vec2>,
/// The shape had more edges than the self-intersection cap checks.
cross_capped: bool,
dups: Vec<(usize, f64)>,
bounds: Bounds2,
},
Skip(&'static str),
}
/// SHAPES: geometry facts for every closed shape that matches the filters in the current space,
/// paged by `offset`/`limit`, with the self-check of what was found, analysed, skipped and
/// flagged.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
@@ -52,15 +63,29 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let s: &Session = s;
let st = s.state()?;
let d = &st.doc;
let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} });
let empty = || {
json!({
"schema": 1,
"count": 0,
"returned": 0,
"offset": offset,
"truncated": false,
"shapes": [],
"skipped": {},
"selfCheck": SelfCheck::new().to_json(),
})
};
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
let extents = d.extents(&st.space);
// No visible extents means no bound to compare an area against (`implausible` skips an
// infinite bound).
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
let tol = tolerance.unwrap_or_else(|| {
let ext = d.extents(&st.space);
let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) };
let diag = if extents.is_empty() { 0.0 } else { extents.min.dist(extents.max) };
(diag * 1e-9).max(1e-12)
});
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new();
let mut check = SelfCheck::new();
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
for e in store
.iter()
@@ -68,31 +93,51 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
{
check.found(1);
match facts(s, e, tol)? {
Facts::Skip(why) => {
let entry = skipped.entry(e.kind.type_name()).or_default();
entry.0 += 1;
entry.1.insert(why);
Facts::Skip(why) => check.skip(e.kind.type_name(), why, 1),
f => {
check.analyzed(1);
if let Facts::Shape { perimeter, area, cross_capped, bounds, .. } = &f {
if *cross_capped {
check.cap("crossingSegments", MAX_CROSSING_SEGMENTS as u64);
}
let diagonal = if bounds.is_empty() { 0.0 } else { bounds.min.dist(bounds.max) };
check.implausible(
e.handle,
e.kind.type_name(),
"perimeter",
*perimeter,
diagonal * 10.0,
"perimeter is more than 10 x the bounding-box diagonal",
);
check.implausible(e.handle, e.kind.type_name(), "area", *area, extents_area, "area is larger than the drawing extents");
}
shapes.push((e, f));
}
f => shapes.push((e, f)),
}
}
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
let skipped: BTreeMap<&str, Value> =
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
let truncated = page.len() < shapes.len().saturating_sub(offset);
if truncated {
check.cap("limit", u64::try_from(limit).unwrap_or(u64::MAX));
}
let skipped = check.skipped_json();
Ok(json!({
"schema": 1,
"count": shapes.len(),
"returned": page.len(),
"offset": offset,
"truncated": page.len() < shapes.len().saturating_sub(offset),
"truncated": truncated,
"shapes": page,
"skipped": skipped,
"selfCheck": check.to_json(),
}))
}
/// One analysed shape as the JSON object the command reports (null if `f` is not a shape).
fn shape_json(e: &Entity, f: &Facts) -> Value {
let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null };
let Facts::Shape { perimeter, area, signed, centroid, cross, dups, .. } = f else { return Value::Null };
json!({
"handle": e.handle.hex(),
"type": e.kind.type_name(),
@@ -106,11 +151,24 @@ fn shape_json(e: &Entity, f: &Facts) -> Value {
})
}
/// The facts SHAPES reports for one entity, or the reason it is not a closed shape: circles,
/// full ellipses, closed or self-closing polylines and splines are measured, arcs and everything
/// else are skipped.
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
Ok(match &e.kind {
EntityKind::Circle(c) => {
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() }
let r = Vec2::new(c.radius, c.radius);
Facts::Shape {
perimeter,
area,
signed: area,
centroid: Some(c.center.xy()),
cross: None,
cross_capped: false,
dups: Vec::new(),
bounds: Bounds2::new(c.center.xy() - r, c.center.xy() + r),
}
}
EntityKind::Ellipse(el) => {
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
@@ -118,7 +176,16 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
return Ok(Facts::Skip("open"));
}
let (area, perimeter) = object_area(s, e.handle)?;
Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() }
Facts::Shape {
perimeter,
area,
signed: area,
centroid: Some(geom.center),
cross: None,
cross_capped: false,
dups: Vec::new(),
bounds: geom.bounds(),
}
}
EntityKind::LwPolyline(pl) => {
let mut vertices = pl.vertices.clone();
@@ -145,7 +212,8 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
(dist <= tol).then_some((i, dist))
})
.collect();
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups }
let (cross, cross_capped) = first_crossing(&g);
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross, cross_capped, dups, bounds: g.bounds() }
}
EntityKind::Spline(sp) => {
let mut pts = sp.tessellate(1e-3);
@@ -162,9 +230,11 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
if pts.len() < 3 {
return Ok(Facts::Skip("degenerate"));
}
let bounds = Bounds2::from_points(pts.iter().copied());
let (area, perimeter) = area_of(&pts);
let g = Polyline::from_points(&pts, true);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() }
let (cross, cross_capped) = first_crossing(&g);
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross, cross_capped, dups: Vec::new(), bounds }
}
EntityKind::Arc(_) => Facts::Skip("open"),
_ => Facts::Skip("not a closed shape"),
@@ -174,13 +244,14 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
/// Shapes with more edges than this report no selfIntersection.
const MAX_CROSSING_SEGMENTS: usize = 4_000;
/// The first crossing between two non-adjacent edges, in edge order.
fn first_crossing(g: &Polyline) -> Option<Vec2> {
/// The first crossing between two non-adjacent edges, in edge order, and whether the shape was
/// too large to check (the cap applies even if a crossing lies among the first edges).
fn first_crossing(g: &Polyline) -> (Option<Vec2>, bool) {
let segs = g.segments();
let n = segs.len();
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
if n > MAX_CROSSING_SEGMENTS {
return None;
return (None, true);
}
for (i, a) in segs.iter().enumerate() {
for (j, b) in segs.iter().enumerate().skip(i + 2) {
@@ -188,9 +259,9 @@ fn first_crossing(g: &Polyline) -> Option<Vec2> {
continue;
}
if let Some(x) = intersect_segments(a, b).first() {
return Some(*x);
return (Some(*x), false);
}
}
}
None
(None, false)
}
+437
View File
@@ -0,0 +1,437 @@
//! USAGE: a read-only cross-reference of the named objects a drawing uses — for each layer,
//! block, text style and linetype, how many objects use it, whether they are only inside block
//! definitions, and which are empty or unused. The unused lists are PURGE's scan (see
//! `cmd::blocks::purge`); nothing here changes or deletes anything.
use std::collections::{HashMap, HashSet};
use cadcraft_doc::{Drawing, Entity, EntityKind};
use serde_json::{Value, json};
use super::super::blocks::purge;
use super::super::*;
use crate::{Result, Session};
const CMD: &str = "usage";
/// Names listed per kind in `unused`; the counts are complete unless the scan was cut short
/// (`incomplete`).
const MAX_NAMES: usize = 5_000;
/// The most objects one scan reads, across model space, the layouts and the block definitions. A
/// drawing with more is reported as `incomplete` instead of with counts that look complete. This
/// is USAGE's own limit; PURGE's scan limit is separate and unchanged.
const MAX_SCAN: usize = 5_000_000;
pub const PARAMS: &str = "{type?: layers|blocks|textstyles|linetypes|all (default all), limit? (default 200), offset?} → \
{schema: 1, count (all items of the type), returned, offset, truncated, incomplete (true when the drawing has more \
objects than one scan reads, so every count, status and summary is partial), incompleteReason? (why), \
items: [{type: layer|block|textStyle|linetype, name, entities (objects in model space and layouts that use it), \
blockEntities (objects in block definitions that use it), references (named objects that use it: a layer's linetype, \
a text style's dimension and multileader styles), used (for a block: reachable from model space, a layout or a dimension), \
insideBlocksOnly (used, but by block definitions only), empty (nothing refers to it), unused (PURGE would offer it), \
current? (the current layer, text style or linetype)}], \
summary: {<kind>: {total, used, insideBlocksOnly, empty, unused}}, \
unused: {<kind>: {count, returned, truncated, names}} (PURGE's scan, in table order)}";
/// The four kinds of named object USAGE reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Category {
Layer,
Block,
TextStyle,
Linetype,
}
impl Category {
const ALL: [Category; 4] = [Category::Layer, Category::Block, Category::TextStyle, Category::Linetype];
/// The singular JSON `type` value of this kind (`layer`, `block`, `textStyle`, `linetype`).
fn key(self) -> &'static str {
match self {
Category::Layer => "layer",
Category::Block => "block",
Category::TextStyle => "textStyle",
Category::Linetype => "linetype",
}
}
/// The JSON key under which this kind's objects are grouped (`layers`, `blocks`,
/// `textStyles`, `linetypes`).
fn plural(self) -> &'static str {
match self {
Category::Layer => "layers",
Category::Block => "blocks",
Category::TextStyle => "textStyles",
Category::Linetype => "linetypes",
}
}
/// The capitalised plural noun used in the human-readable message.
fn label(self) -> &'static str {
match self {
Category::Layer => "Layers",
Category::Block => "Blocks",
Category::TextStyle => "Text styles",
Category::Linetype => "Linetypes",
}
}
/// The PURGE kind whose scan answers whether this kind's objects are unused.
fn purge(self) -> purge::Kind {
match self {
Category::Layer => purge::Kind::Layers,
Category::Block => purge::Kind::Blocks,
Category::TextStyle => purge::Kind::TextStyles,
Category::Linetype => purge::Kind::Linetypes,
}
}
/// `None` for an unknown name, `Some(None)` for all kinds.
fn parse(s: &str) -> Option<Option<Category>> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "all" => Some(None),
"layer" | "layers" => Some(Some(Category::Layer)),
"block" | "blocks" => Some(Some(Category::Block)),
"style" | "styles" | "textstyle" | "textstyles" | "text_style" | "text_styles" => Some(Some(Category::TextStyle)),
"linetype" | "linetypes" | "ltype" | "ltypes" => Some(Some(Category::Linetype)),
_ => None,
}
}
}
/// Objects in model space and the layouts (`false`), and in block definitions (`true`), up to
/// `limit` — the USAGE scan limit, past which later objects are not read.
fn each_entity(d: &Drawing, limit: usize) -> impl Iterator<Item = (&Entity, bool)> {
let top = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| (&**e, false));
let blocks = d.blocks.values().flat_map(|b| b.entities.iter()).map(|e| (&**e, true));
top.chain(blocks).take(limit)
}
/// Objects in model space, the layouts and the block definitions — everything one scan reads.
fn entity_total(d: &Drawing) -> usize {
d.blocks.values().fold(d.entity_count(), |n, b| n.saturating_add(b.entities.len()))
}
/// A scan that stopped at its limit: the drawing holds `total` objects but only the first
/// `scanned` were read, so objects after the limit are missing from the counts, verdicts and
/// summary. Present in the report as `incomplete`/`incompleteReason`, never silently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Incomplete {
scanned: usize,
total: usize,
}
impl Incomplete {
/// Why the report is partial, naming both counts so a caller can judge it.
fn reason(self) -> String {
format!(
"USAGE read the first {} of {} objects (model space, layouts and block definitions); \
references after the limit are missing, so counts, statuses and summaries are partial",
self.scanned, self.total
)
}
}
/// Uses per lower-case name: (in model space and layouts, in block definitions).
type Uses = HashMap<String, (usize, usize)>;
/// Counts one use of a named object, separately for model space and the layouts (`in_block`
/// false) and for block definitions (`in_block` true); names compare case-insensitively.
fn bump(map: &mut Uses, name: &str, in_block: bool) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
if in_block {
slot.1 = slot.1.saturating_add(1);
} else {
slot.0 = slot.0.saturating_add(1);
}
}
/// Counts one named table entry that refers to `name`: a layer's linetype, or a dimension or
/// multileader style's text style. Names compare case-insensitively.
fn bump_reference(map: &mut HashMap<String, usize>, name: &str) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
*slot = slot.saturating_add(1);
}
/// `ByLayer` and `ByBlock` name a value, not the linetype table's entries.
fn linetype_value(name: &str) -> bool {
name.eq_ignore_ascii_case("ByLayer") || name.eq_ignore_ascii_case("ByBlock")
}
/// Every use of the drawing's layers, blocks, text styles and linetypes, in one pass over the
/// objects (plus the block reachability PURGE already computes).
#[derive(Default)]
struct Scan {
layers: Uses,
blocks: Uses,
styles: Uses,
linetypes: Uses,
/// Dimension and multileader styles that name a text style.
style_refs: HashMap<String, usize>,
/// Layers that name a linetype.
linetype_refs: HashMap<String, usize>,
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
used_blocks: HashSet<String>,
/// Set when the scan stopped at its limit, so every count and verdict is partial.
incomplete: Option<Incomplete>,
}
impl Scan {
/// Scans the drawing once, reading at most `limit` objects: counts each entity's layer,
/// explicit linetype, block, text style and attribute style, plus the table references, and
/// borrows block reachability from PURGE. Records whether the limit cut the drawing short.
fn new(d: &Drawing, limit: usize) -> Scan {
let total = entity_total(d);
let scanned = total.min(limit);
let mut sc = Scan { used_blocks: purge::used_blocks(d), ..Scan::default() };
sc.incomplete = (scanned < total).then_some(Incomplete { scanned, total });
for (e, in_block) in each_entity(d, scanned) {
bump(&mut sc.layers, &e.common.layer, in_block);
if !linetype_value(&e.common.linetype) {
bump(&mut sc.linetypes, &e.common.linetype, in_block);
}
match &e.kind {
EntityKind::Insert(i) => {
bump(&mut sc.blocks, &i.block, in_block);
for a in &i.attribs {
bump(&mut sc.styles, &a.text.style, in_block);
}
}
EntityKind::Dimension(dm) => {
if let Some(b) = &dm.block {
bump(&mut sc.blocks, b, in_block);
}
}
EntityKind::MLeader(m) => {
if let Some(b) = &m.block {
bump(&mut sc.blocks, &b.block, in_block);
}
if let Some(t) = &m.text {
bump(&mut sc.styles, &t.style, in_block);
}
}
EntityKind::Text(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::MText(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::AttDef(a) => bump(&mut sc.styles, &a.text.style, in_block),
_ => {}
}
}
for st in &d.dim_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for st in &d.mleader_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for l in &d.layers {
if !linetype_value(&l.linetype) {
bump_reference(&mut sc.linetype_refs, &l.linetype);
}
}
sc
}
}
/// PURGE's unused lists, kept once so each item can say whether PURGE would offer it.
struct Unused {
lists: Vec<(Category, Vec<String>, HashSet<String>)>,
}
impl Unused {
/// Runs PURGE's unused scan for all four kinds, keeping each list in table order plus a
/// lower-case set for fast membership checks.
fn new(d: &Drawing) -> Unused {
let lists = Category::ALL
.into_iter()
.map(|c| {
let names = purge::candidates(d, c.purge());
let lower = names.iter().map(|n| n.to_ascii_lowercase()).collect();
(c, names, lower)
})
.collect();
Unused { lists }
}
/// Whether PURGE would offer `name` of kind `c` as unused (case-insensitive).
fn has(&self, c: Category, name: &str) -> bool {
self.lists.iter().find(|(k, ..)| *k == c).is_some_and(|(_, _, lower)| lower.contains(&name.to_ascii_lowercase()))
}
/// How many objects of kind `c` PURGE would offer — the full count, even when the name list
/// in the JSON output is truncated.
fn count(&self, c: Category) -> usize {
self.lists.iter().find(|(k, ..)| *k == c).map_or(0, |(_, names, _)| names.len())
}
/// The `unused` member: per kind, the total count, the returned page of names capped at
/// `MAX_NAMES`, and whether that page is truncated.
fn json(&self) -> Value {
let mut out = serde_json::Map::new();
for (c, names, _) in &self.lists {
let page: Vec<&String> = names.iter().take(MAX_NAMES).collect();
out.insert(
c.plural().into(),
json!({
"count": names.len(),
"returned": page.len(),
"truncated": page.len() < names.len(),
"names": page,
}),
);
}
Value::Object(out)
}
}
/// One report item: the use counts, the caller's `used`/`empty` verdicts, `insideBlocksOnly`
/// derived from those counts and PURGE's `unused` answer. `current` is emitted only for the kinds
/// that have a current entry (layers, text styles and linetypes).
fn item_json(
c: Category,
name: &str,
entities: usize,
block_entities: usize,
references: usize,
used: bool,
empty: bool,
current: Option<bool>,
unused: &Unused,
) -> Value {
let mut v = json!({
"type": c.key(),
"name": name,
"entities": entities,
"blockEntities": block_entities,
"references": references,
"used": used,
"insideBlocksOnly": used && entities == 0 && block_entities > 0 && references == 0,
"empty": empty,
"unused": unused.has(c, name),
});
if let (Some(cur), Some(o)) = (current, v.as_object_mut()) {
o.insert("current".into(), json!(cur));
}
v
}
/// Builds one item per named object in table order — layers, blocks, text styles, linetypes —
/// reading the scanned use counts and adding the current-object flag where the drawing has one.
fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let mut out = Vec::new();
let current_layer = d.header.str("CLAYER", "0");
for l in &d.layers {
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
let uses = e.saturating_add(b);
let cur = l.name.eq_ignore_ascii_case(&current_layer);
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
}
for b in d.blocks.values() {
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
// A definition is used when a reference to it is reachable, as PURGE counts it.
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
}
let current_style = d.header.str("TEXTSTYLE", "Standard");
for t in &d.text_styles {
let (e, b) = scan.styles.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_style);
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
let current_linetype = d.header.str("CELTYPE", "ByLayer");
for t in &d.linetypes {
let (e, b) = scan.linetypes.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_linetype);
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
out
}
/// Totals per kind over every item — total, used, inside blocks only, empty — plus how many
/// objects of that kind PURGE would offer.
fn summary(items: &[Value], unused: &Unused) -> Value {
let mut out = serde_json::Map::new();
for c in Category::ALL {
let mut total = 0usize;
let mut used = 0usize;
let mut inside = 0usize;
let mut empty = 0usize;
for it in items.iter().filter(|i| i["type"] == c.key()) {
total = total.saturating_add(1);
used = used.saturating_add(usize::from(it["used"].as_bool() == Some(true)));
inside = inside.saturating_add(usize::from(it["insideBlocksOnly"].as_bool() == Some(true)));
empty = empty.saturating_add(usize::from(it["empty"].as_bool() == Some(true)));
}
out.insert(c.plural().into(), json!({ "total": total, "used": used, "insideBlocksOnly": inside, "empty": empty, "unused": unused.count(c) }));
}
Value::Object(out)
}
/// The human-readable summary, one line per kind, plus a note when the filter matched no named
/// objects at all and a note when the scan was cut short.
fn message(items: &[&Value], summary: &Value, incomplete: Option<Incomplete>) -> String {
let mut lines = Vec::new();
for c in Category::ALL {
let s = &summary[c.plural()];
lines.push(format!(
"{}: {} total, {} used, {} inside blocks only, {} empty, {} unused.",
c.label(),
s["total"].as_u64().unwrap_or(0),
s["used"].as_u64().unwrap_or(0),
s["insideBlocksOnly"].as_u64().unwrap_or(0),
s["empty"].as_u64().unwrap_or(0),
s["unused"].as_u64().unwrap_or(0),
));
}
if items.is_empty() {
lines.push("There are no named objects of this type.".into());
}
if let Some(inc) = incomplete {
lines.push(format!("Scan incomplete: read the first {} of {} objects; the counts above are partial.", inc.scanned, inc.total));
}
lines.join("\n")
}
/// Runs `USAGE`: validates `type`, `limit` and `offset` (rejecting bad input without touching the
/// drawing), scans read-only, and returns the paged items with the per-kind summary, PURGE's
/// unused lists and the message.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let ty = match given("type") {
None => None,
Some(Value::String(t)) => {
Category::parse(t).ok_or_else(|| bad(CMD, format!("`type` must be layers, blocks, textstyles, linetypes or all, got \"{t}\"")))?
}
Some(v) => return Err(bad(CMD, format!("`type` must be a string, got {v}"))),
};
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let scan = Scan::new(d, MAX_SCAN);
let unused = Unused::new(d);
let all = items(&scan, &unused, d);
let matched: Vec<&Value> = all.iter().filter(|i| ty.is_none_or(|c| i["type"] == c.key())).collect();
let total = matched.len();
let page: Vec<&Value> = matched.iter().skip(offset).take(limit).copied().collect();
let summary = summary(&all, &unused);
let mut out = json!({
"schema": 1,
"count": total,
"returned": page.len(),
"offset": offset,
"truncated": page.len() < total.saturating_sub(offset),
"incomplete": scan.incomplete.is_some(),
"items": page,
"summary": summary,
"unused": unused.json(),
"message": message(&matched, &summary, scan.incomplete),
});
if let (Some(inc), Some(o)) = (scan.incomplete, out.as_object_mut()) {
o.insert("incompleteReason".into(), json!(inc.reason()));
}
Ok(out)
}
#[cfg(test)]
#[path = "usage_tests.rs"]
mod tests;
@@ -0,0 +1,59 @@
//! Unit tests for USAGE's scan limit (#1439): a scan cut at its limit must say so. A drawing with
//! more than 5,000,000 objects is impractical to build in a test, so the limit is a parameter and
//! these tests cut a tiny drawing instead.
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Line};
use cadcraft_geom::Vec3;
use super::{Incomplete, Scan, entity_total};
/// A LINE on `layer`, one unit long.
fn line(handle: u64, layer: &str) -> Entity {
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }));
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// A drawing with `top` lines on layer Used in model space and `held` lines on layer Held in the
/// block HELD, so the block's objects come last in the scan, exactly as they do in a real drawing.
fn drawing(top: usize, held: usize) -> Drawing {
let mut d = Drawing::new_metric();
for i in 0..top {
d.model.push(line(0x100 + i as u64, "Used"));
}
let mut b = Block::new("HELD");
for i in 0..held {
b.entities.push(line(0x200 + i as u64, "Held"));
}
d.blocks.insert("HELD".into(), Arc::new(b));
d
}
/// Guards the detector's boundary: a drawing holding exactly the limit is read whole and is not
/// reported as cut short, so `incomplete` never cries wolf on the last readable object.
#[test]
fn a_drawing_at_the_limit_is_complete() {
let d = drawing(2, 1);
assert_eq!(entity_total(&d), 3);
let sc = Scan::new(&d, 3);
assert_eq!(sc.incomplete, None);
assert_eq!(sc.layers.get("used").copied(), Some((2, 0)));
assert_eq!(sc.layers.get("held").copied(), Some((0, 1)));
}
/// Guards that a scan stopped one object early reports what it omitted and why: HELD's line is
/// past the limit, so its layer looks unused — the miscount the `incomplete` signal exists to
/// prevent a caller from trusting.
#[test]
fn a_cut_scan_reports_the_objects_it_omitted() {
let d = drawing(2, 1);
let sc = Scan::new(&d, 2);
let inc = sc.incomplete.expect("a cut scan must report it");
assert_eq!(inc, Incomplete { scanned: 2, total: 3 });
assert_eq!(sc.layers.get("held").copied(), None);
let reason = inc.reason();
assert!(reason.contains("first 2 of 3 objects"), "{reason}");
assert!(reason.contains("partial"), "{reason}");
}
+17 -2
View File
@@ -270,9 +270,13 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
let from = layout_name("layout.rename", s.doc()?, &from)?;
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
let d = s.doc_mut()?;
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == from) {
l.name = to.clone();
}
if d.active_layout.as_ref().is_some_and(|a| a.eq_ignore_ascii_case(&from)) {
d.active_layout = Some(to.clone());
}
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
let st = s.state_mut()?;
for (sp, _) in st.views.iter_mut() {
@@ -832,7 +836,10 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
}
#[cfg(target_arch = "wasm32")]
Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))
{
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
}
}
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
}
@@ -925,6 +932,14 @@ mod tests {
assert!(s.doc().unwrap().layout("Layout3").is_none());
}
#[test]
fn rename_keeps_the_active_layout_whatever_its_case() {
let mut s = session_with_model();
s.doc_mut().unwrap().active_layout = Some("layout1".into());
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
}
#[test]
fn rename_copy_delete() {
let mut s = session_with_model();
+2 -2
View File
@@ -637,13 +637,13 @@ impl Interactive for MviewM {
Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW)
.kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"])
.default("Right"),
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
Stage::Polygon(pi) => pi.prompt(),
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a),
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
}
}
+20 -3
View File
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("appload", "Load Application...", run_appload)
.menu(&["Tools", "Load Application..."])
.alias(&["ap"])
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, commands: [commands the file defined], error?}")
.noundo()
.interactive(|_| Ok(Box::new(ApploadM))),
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
@@ -23,6 +23,11 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Whether `path` names an AutoLISP routine: APPLOAD loads `.lsp` files only, in any case.
fn is_lisp_path(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp"))
}
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
fn source(p: &Value) -> Result<(String, String)> {
if let Some(text) = str_param(p, "text") {
@@ -32,7 +37,14 @@ fn source(p: &Value) -> Result<(String, String)> {
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
}
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
// The path is read as it is given (a JSON caller may name a file or folder with spaces);
// only a path of blanks alone is refused.
let path = str_param(p, "path")
.filter(|p| !p.trim().is_empty())
.ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
if !is_lisp_path(path) {
return Err(bad("appload", format!("{path}: not an AutoLISP file (APPLOAD loads .lsp files)")));
}
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
Ok((path.to_string(), text))
}
@@ -49,9 +61,14 @@ fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
let (name, text) = source(p)?;
let before = s.lisp_commands();
let from = s.log.len();
let r = s.lisp_load(&name, &text, true)?;
Ok(report(s, from, r))
let mut out = report(s, from, r);
// The commands the file defined, so a caller can run them next (a failed file may still
// have defined some before the error).
out["commands"] = json!(s.lisp_commands().into_iter().filter(|c| !before.contains(c)).collect::<Vec<_>>());
Ok(out)
}
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
+66 -50
View File
@@ -310,6 +310,67 @@ fn segment_dir(m: &MLeader) -> Option<Vec2> {
(v.len() > 1e-12).then(|| v.normalized())
}
fn align_parallel(items: &[(Handle, MLeader)], reference: Option<&MLeader>) -> Result<Vec<(Handle, Vec2)>> {
let rdir = reference.and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
let mut moves = Vec::new();
for (h, m) in items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
Ok(moves)
}
fn align_distribute(mut items: Vec<(Handle, MLeader)>, line: Option<(Vec2, Vec2)>) -> Result<Vec<(Handle, Vec2)>> {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
let mut moves = Vec::new();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
Ok(moves)
}
fn align_directional(
items: &[(Handle, MLeader)],
reference: Option<&MLeader>,
line: Option<(Vec2, Vec2)>,
spacing: Spacing,
) -> Result<Vec<(Handle, Vec2)>> {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
let mut moves = Vec::new();
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
Ok(moves)
}
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
@@ -331,56 +392,11 @@ fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, l
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
};
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
match spacing {
Spacing::Parallel => {
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
for (h, m) in &items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
}
Spacing::Distribute => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
}
Spacing::Current | Spacing::Value(_) => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in &items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
}
}
let moves = match spacing {
Spacing::Parallel => align_parallel(&items, reference.as_ref())?,
Spacing::Distribute => align_distribute(items, line)?,
Spacing::Current | Spacing::Value(_) => align_directional(&items, reference.as_ref(), line, spacing)?,
};
move_content(s, &moves)?;
Ok(moves.len())
}
@@ -120,3 +120,88 @@ fn hostile_mleader_edit_params_never_panic() {
}
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
}
mod units {
use super::super::{Spacing, align_directional, align_distribute, align_parallel};
use cadcraft_doc::{Handle, MLeader};
use cadcraft_geom::{Vec2, Vec3};
fn unit_ml(arrow: [f64; 2], land: [f64; 2]) -> MLeader {
MLeader {
leaders: vec![vec![Vec3::new(arrow[0], arrow[1], 0.0)]],
landing: Vec3::new(land[0], land[1], 0.0),
dogleg: 0.0,
text: None,
style: String::new(),
arrow_size: 1.0,
spline: false,
block: None,
}
}
fn no_leader(land: [f64; 2]) -> MLeader {
MLeader {
leaders: vec![],
landing: Vec3::new(land[0], land[1], 0.0),
dogleg: 0.0,
text: None,
style: String::new(),
arrow_size: 1.0,
spline: false,
block: None,
}
}
/// A five-unit-long leader whose direction differs from the reference's `+y`: moving the
/// arrow onto `y` plus `5` (a five-unit parallel push) and back along the landing gives
/// `(-3, 1)`.
#[test]
fn parallel_requires_a_reference_and_skips_leaders() {
assert!(align_parallel(&[(Handle(1), unit_ml([0.0, 0.0], [3.0, 4.0]))], None).is_err());
let d = align_parallel(
&[(Handle(2), no_leader([1.0, 1.0])), (Handle(3), unit_ml([0.0, 0.0], [3.0, 4.0]))],
Some(&unit_ml([0.0, 0.0], [0.0, 4.0])),
)
.unwrap();
assert_eq!(d.len(), 1, "a multileader without a leader line is skipped");
assert_eq!(d[0].0, Handle(3));
assert!((d[0].1.x + 3.0).abs() < 1e-9);
assert!((d[0].1.y - 1.0).abs() < 1e-9);
}
#[test]
fn distribute_orders_landings_along_the_line() {
let mk = |h: u64, land: [f64; 2]| (Handle(h), unit_ml([0.0, 0.0], land));
assert!(align_distribute(vec![mk(1, [2.0, 0.0])], None).is_err(), "needs two points");
assert!(align_distribute(Vec::new(), Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0)))).is_err(), "coinciding points give no direction");
let items = vec![mk(1, [2.0, 0.0]), mk(2, [0.0, 0.0])];
let d = align_distribute(items, Some((Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)))).unwrap();
assert_eq!(d[0].0, Handle(2), "sorted by projection onto the line");
assert!((d[0].1.x - 0.0).abs() < 1e-9 && (d[0].1.y - 0.0).abs() < 1e-9, "first fraction is 0");
assert_eq!(d[1].0, Handle(1));
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 0.0).abs() < 1e-9, "last fraction is 1");
}
#[test]
fn directional_projects_landings_and_supports_spacing() {
let line = Some((Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)));
assert!(align_directional(&[], None, None, Spacing::Current).is_err(), "needs a direction");
assert!(
align_directional(&[], None, Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0))), Spacing::Current).is_err(),
"a zero direction is an error"
);
// Without a reference the origin is the first point: the landing is projected onto it.
let d = align_directional(&[(Handle(7), unit_ml([0.0, 0.0], [1.0, 5.0]))], None, line, Spacing::Current).unwrap();
assert_eq!(d.len(), 1);
assert!((d[0].1.x - 0.0).abs() < 1e-9);
assert!((d[0].1.y + 5.0).abs() < 1e-9);
// With a reference as origin, given spacing counts outward on both sides of it.
let items = vec![(Handle(1), unit_ml([0.0, 0.0], [4.0, 0.0])), (Handle(2), unit_ml([0.0, 0.0], [-4.0, 0.0]))];
let d = align_directional(&items, Some(&unit_ml([0.0, 0.0], [0.0, 4.0])), line, Spacing::Value(2.0)).unwrap();
assert_eq!(d.len(), 2);
assert_eq!(d[0].0, Handle(1));
assert!((d[0].1.x + 2.0).abs() < 1e-9 && (d[0].1.y - 4.0).abs() < 1e-9);
assert_eq!(d[1].0, Handle(2));
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 4.0).abs() < 1e-9);
}
}
+1
View File
@@ -33,6 +33,7 @@ pub mod plotters;
mod props;
pub use draw2::breakline::BreaklineOptions;
pub use props::MatchProps;
pub(crate) use props::set_current_color_value;
mod qselect;
mod selecttools;
mod settings;
+90 -20
View File
@@ -219,6 +219,22 @@ impl StretchArea {
}
}
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
/// selection tests the drawn element lines, which sit away from the control vertices by the
/// multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
let Some(store) = d.space(space) else { return };
for e in store.iter() {
if let EntityKind::MLine(m) = &e.kind
&& d.is_visible(e)
&& !hs.contains(&e.handle)
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
{
hs.push(e.handle);
}
}
}
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
@@ -240,6 +256,7 @@ fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
}
}
}
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)),
EntityKind::Arc(a) => {
// Arcs keep their shape: move when the centre is inside.
if inside(a.center.xy()) {
@@ -287,6 +304,7 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let bx = cadcraft_geom::Bounds2::new(*a, *b);
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
};
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
hs.retain(|h| !super::curves::is_locked(s, *h));
let doc = s.doc_mut()?;
let areas = [area];
@@ -353,7 +371,12 @@ impl StretchM {
/// of two) the mode is dropped.
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
let space = s.space();
let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
(SelectKind::Box, Some((a, b))) => b.x < a.x,
_ => false,
};
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
@@ -363,6 +386,9 @@ impl StretchM {
_ => None,
};
let whole = area.is_none();
if let (Some(a), true, false) = (&area, crossing, self.removing) {
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
}
if let (Some(a), false) = (area, self.removing) {
self.windows.push(a);
}
@@ -400,8 +426,11 @@ impl StretchM {
Some(c) => {
let bx = cadcraft_geom::Bounds2::new(c, p);
let space = s.space();
let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
if !self.removing {
if p.x < c.x {
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
}
self.windows.push(StretchArea::Box(bx));
}
self.add(s, hs, false);
@@ -488,7 +517,7 @@ impl Interactive for StretchM {
return m.prompt();
}
match (self.phase, self.corner) {
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c),
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
@@ -564,6 +593,21 @@ impl Interactive for StretchM {
}
}
/// Whether OFFSET can work on this kind of object at all.
fn offsettable(k: &EntityKind) -> bool {
matches!(
k,
EntityKind::Line(_)
| EntityKind::Circle(_)
| EntityKind::Arc(_)
| EntityKind::Ellipse(_)
| EntityKind::LwPolyline(_)
| EntityKind::Spline(_)
| EntityKind::XLine(_)
| EntityKind::Ray(_)
)
}
/// Offset an entity by `dist` towards `side`. Returns the new geometry.
pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<EntityKind> {
match k {
@@ -1353,21 +1397,17 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
let g = cadcraft_render::dimension_in(d, dm);
let mut v: Vec<Entity> = g
.lines
.iter()
.filter_map(|l| {
if l.len() == 2 {
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
} else {
Some(Entity {
handle: Handle(0),
common: e.common.clone(),
kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false),
})
}
})
.collect();
let line_entity = |l: &Vec<Vec2>| {
if l.len() == 2 {
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
} else {
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
}
};
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
let text_marks = &g.text_marks;
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
for t in &g.fills {
if let [a, b, c] = t.as_slice() {
v.push(Entity {
@@ -1377,6 +1417,20 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
});
}
}
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
if style.text_fill == 2
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
{
let fill = style.text_fill_color;
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
v.push(Entity {
handle: Handle(0),
common: cadcraft_doc::Common { color, ..e.common.clone() },
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
});
}
v.extend(marks(true).filter_map(&line_entity));
if !g.value.is_empty() {
v.push(Entity {
handle: Handle(0),
@@ -1388,7 +1442,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
attach: 5,
rotation: g.text_angle,
style: style.text_style.clone(),
contents: g.value.clone(),
contents: g.mtext_contents(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
@@ -2239,8 +2293,24 @@ impl Interactive for OffsetM {
fn name(&self) -> &'static str {
"OFFSET"
}
/// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt
/// sequence goes distance then side instead of asking for the object again; any other
/// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is
/// likewise ignored and cleared.
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.set_selection(Vec::new());
// Exactly one offsettable object selected before the command is taken at once, so only the
// side is asked for; the object loop stays for the ones after it (issue #1610). A multiple
// selection is left for the select prompt, as AutoCAD does.
let pre = s.selection();
if s.settings.pickfirst
&& let [h] = pre[..]
&& s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind))
{
self.picked = Some(h);
s.set_selection(vec![h]);
} else {
s.set_selection(Vec::new());
}
let (erase, current) = offset_options(s);
s.echo(format!(
"Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0",
+1
View File
@@ -52,6 +52,7 @@ pub const PROFILE_VARS: &[&str] = &[
"PICKADD",
"PICKDRAG",
"MAXARRAY",
"MEASUREINIT",
];
/// The most profile entries [`restore`] reads (a saved profile is untrusted input).
+1 -1
View File
@@ -365,7 +365,7 @@ impl Interactive for PlotWindowM {
fn prompt(&self, _s: &Session) -> Prompt {
match self.first {
None => Prompt::new("Specify first corner", Accept::POINT),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+83 -3
View File
@@ -16,7 +16,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
.menu(&["Modify", "Match Properties"])
.alias(&["ma", "painter"])
@@ -32,6 +32,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
CommandSpec::new("thickness", "Thickness", run_thickness)
.menu(&["Format", "Thickness"])
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
.transparent()
.interactive(|_| Ok(Box::new(ThicknessM))),
CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"),
CommandSpec::new("style", "Text Style...", run_style)
.menu(&["Format", "Text Style..."])
@@ -82,6 +87,7 @@ pub(super) fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity)
"lineweight": e.common.lineweight.name(),
"ltscale": e.common.ltscale,
"transparency": transparency_value(e.common.transparency),
"thickness": e.common.thickness,
"visible": e.common.visible,
});
if cadcraft_doc::can_be_annotative(&e.kind)
@@ -185,6 +191,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
.get("transparency")
.map(|v| parse_transparency(v).ok_or_else(|| bad("properties.set", "transparency must be ByLayer, ByBlock or a percentage from 0 to 90")))
.transpose()?;
let thickness = p
.get("thickness")
.map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("properties.set", "thickness must be a finite number")))
.transpose()?;
let lw = p.get("lineweight").map(|v| super::layer::parse_lw(v).ok_or_else(|| bad("properties.set", "bad lineweight"))).transpose()?;
let d = s.doc_mut()?;
// An explicit object linetype must be ByLayer, ByBlock or a loaded definition (as `linetype current` requires).
@@ -229,6 +239,9 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(t) = transparency {
e.common.transparency = t;
}
if let Some(th) = thickness {
e.common.thickness = th;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
e.common.visible = v;
}
@@ -410,7 +423,25 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
let c = p.get("color").and_then(parse_color).ok_or_else(|| bad("color", "`color` is required"))?;
let d = s.doc_mut()?;
set_current_color(s.doc_mut()?, c);
ok()
}
/// `setvar CECOLOR`: the same input as the `color` command, stored the same way.
pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> {
// An out-of-range index is refused here, where `color` folds it to ByLayer.
let c = match v {
Value::String(t) => Color::parse(t),
Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())),
_ => None,
}?;
set_current_color(s.doc_mut().ok()?, c);
Some(())
}
/// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must
/// clear a stale one.
fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) {
match c {
Color::True(rgb) => {
d.header.set_i64("CECOLOR", i64::from(cadcraft_color::nearest_aci(rgb)));
@@ -421,7 +452,6 @@ fn run_color(s: &mut Session, p: &Value) -> Result<Value> {
d.header.vars.remove("CECOLOR_RGB");
}
}
ok()
}
mod linfile;
@@ -509,6 +539,56 @@ impl Interactive for LtscaleM {
}
}
/// The largest THICKNESS, either way.
const MAX_THICKNESS: f64 = 1e12;
fn thickness_in_range(t: f64) -> bool {
t.is_finite() && t.abs() <= MAX_THICKNESS
}
fn run_thickness(s: &mut Session, p: &Value) -> Result<Value> {
let t = f64_req("thickness", p, "thickness")?;
if !thickness_in_range(t) {
return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)"));
}
s.doc_mut()?.header.set_f64("THICKNESS", t);
Ok(json!({ "thickness": t }))
}
/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:".
struct ThicknessM;
impl Interactive for ThicknessM {
fn name(&self) -> &'static str {
"THICKNESS"
}
fn prompt(&self, s: &Session) -> Prompt {
let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0);
Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}"))
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match i {
Input::Enter | Input::Cancel => return Ok(Step::Done),
Input::Text(t) => t,
_ => return Ok(Step::Continue),
};
match super::machines::number(&text) {
Some(t) if thickness_in_range(t) => {
run_thickness(s, &json!({ "thickness": t }))?;
Ok(Step::Done)
}
Some(_) => {
s.echo("Requires a value between -1e12 and 1e12.");
Ok(Step::Continue)
}
None => {
s.echo("Requires a number.");
Ok(Step::Continue)
}
}
}
}
fn run_units(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut()?;
for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] {
+202 -29
View File
@@ -1,7 +1,7 @@
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::Vec2;
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::*;
@@ -27,11 +27,24 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
.params("{mode: distance|radius|angle|area, ...}")
.params("{mode: distance|radius|angle|area|volume, ...}")
.noundo()
.interactive(|_| Ok(Box::<MeasureM>::default())),
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
CommandSpec::new("radius.measure", "Radius", run_radius)
.menu(&["Tools", "Inquiry", "Radius"])
.params("{handle, point?}")
.noundo()
.interactive(|_| Ok(Box::new(RadiusM))),
CommandSpec::new("angle.measure", "Angle", run_angle)
.menu(&["Tools", "Inquiry", "Angle"])
.params("{vertex, p1, p2} | {h1, h2}")
.noundo()
.interactive(|_| Ok(Box::<AngleM>::default())),
CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume)
.menu(&["Tools", "Inquiry", "Volume"])
.params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}")
.noundo()
.interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))),
]
}
@@ -283,11 +296,70 @@ fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Circle(c)) => c.radius,
Some(EntityKind::Arc(a)) => a.radius,
_ => return Err(bad("radius.measure", "select an arc or circle")),
Some(EntityKind::LwPolyline(_)) => {
let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?;
match super::annotate::segment_at(s, h, at) {
Some(Segment::Arc { arc, .. }) => arc.radius,
_ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")),
}
}
_ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")),
};
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
}
/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked
/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle
/// at once.
struct RadiusM;
impl RadiusM {
/// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt.
fn pick(s: &mut Session, i: Input) -> Result<Step> {
let Input::Point(p) = i else { return Ok(Step::Done) };
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) {
Ok(r) => {
echo_message(s, &r);
Ok(Step::Done)
}
Err(_) => {
s.echo("Select an arc, circle or polyline arc.");
Ok(Step::Continue)
}
}
}
}
impl Interactive for RadiusM {
fn name(&self) -> &'static str {
"MEASUREGEOM"
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Select arc or circle", Accept::POINT)
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) };
match run_radius(s, &json!({ "handle": h.0 })) {
Ok(r) => {
echo_message(s, &r);
Ok(Step::Done)
}
Err(_) => Ok(Step::Continue),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
Self::pick(s, i)
}
}
/// Echo each line of a result's `message`.
fn echo_message(s: &mut Session, r: &Value) {
for l in r["message"].as_str().unwrap_or_default().lines() {
s.echo(l.to_string());
}
}
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
(v, a, b)
@@ -299,17 +371,29 @@ fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
_ => None,
};
let ((a1, a2), (b1, b2)) =
let (l1, l2) =
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
(x, far(a1, a2), far(b1, b2))
lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?
};
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
if ang > 180.0 {
ang = 360.0 - ang;
}
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
let ang = vertex_angle(v, a, b);
Ok(json!({ "angle": ang, "message": angle_text(ang) }))
}
/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel.
fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> {
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
Some((x, far(a1, a2), far(b1, b2)))
}
/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180.
fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 {
let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
if ang > 180.0 { 360.0 - ang } else { ang }
}
fn angle_text(ang: f64) -> String {
format!("Angle = {ang:.4}°")
}
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
@@ -318,11 +402,12 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
"radius" | "r" => run_radius(s, p),
"angle" | "a" => run_angle(s, p),
"area" | "ar" => s.execute("area", p),
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
"volume" | "v" => super::inquiry::run_volume(s, p),
_ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")),
}
}
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick.
/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick.
#[derive(Default)]
struct MeasureM {
inner: Option<Box<dyn Interactive>>,
@@ -339,7 +424,7 @@ impl Interactive for MeasureM {
Some(m) => m.prompt(s),
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
.kw(&["Distance", "Radius", "ARea"])
.kw(&["Distance", "Radius", "Angle", "ARea", "Volume"])
.default("Distance"),
}
}
@@ -348,24 +433,15 @@ impl Interactive for MeasureM {
return m.input(s, i);
}
if self.radius {
let Input::Point(p) = i else { return Ok(Step::Done) };
let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) };
match run_radius(s, &json!({ "handle": h.0 })) {
Ok(r) => {
for l in r["message"].as_str().unwrap_or_default().lines() {
s.echo(l.to_string());
}
return Ok(Step::Done);
}
Err(_) => s.echo("Select an arc or circle."),
}
return Ok(Step::Continue);
return RadiusM::pick(s, i);
}
match i {
Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())),
Input::Keyword(k) => match k.as_str() {
"Radius" => self.radius = true,
"Angle" => self.inner = Some(Box::<AngleM>::default()),
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
"Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())),
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
},
_ => {}
@@ -377,6 +453,103 @@ impl Interactive for MeasureM {
}
}
/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a
/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex
/// between two endpoints.
#[derive(Default)]
struct AngleM {
/// The first line picked.
first: Option<(Vec2, Vec2)>,
/// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it).
vertex: Option<Vec<Vec2>>,
}
impl AngleM {
fn finish(s: &mut Session, ang: f64) -> Result<Step> {
s.echo(angle_text(ang));
Ok(Step::Done)
}
}
impl Interactive for AngleM {
fn name(&self) -> &'static str {
"MEASUREGEOM"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(pts) = &self.vertex {
let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or("");
return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied());
}
match self.first {
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
Some(_) => Prompt::new("Select second line", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(pts) = &mut self.vertex {
return match i {
Input::Point(p) => {
pts.push(p);
match pts.as_slice() {
[v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)),
_ => Ok(Step::Continue),
}
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
}
let p = match i {
Input::Enter if self.first.is_none() => {
self.vertex = Some(Vec::new());
return Ok(Step::Continue);
}
Input::Point(p) => p,
Input::Enter => return Ok(Step::Cancel),
_ => return Ok(Step::Continue),
};
let Some(h) = super::curves::pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let circle = s
.doc()?
.entity(h)
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
if let Some(c) = circle.filter(|_| self.first.is_none()) {
self.vertex = Some(vec![c.center, c.closest(p)]);
return Ok(Step::Continue);
}
match super::annotate::segment_at(s, h, p) {
Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()),
Some(Segment::Line(l)) => match self.first {
None => {
self.first = Some((l.a, l.b));
Ok(Step::Continue)
}
Some(first) => match lines_vertex(first, (l.a, l.b)) {
Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)),
None => {
s.echo("The lines are parallel.");
Ok(Step::Done)
}
},
},
_ => {
s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." });
Ok(Step::Continue)
}
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.vertex.as_deref() {
Some([v]) => vec![super::helpers::line(*v, c)],
Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)],
_ => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -73,7 +73,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn peek(&mut self) -> Option<u8> {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
self.i += 1;
}
self.s.get(self.i).copied()
+1 -1
View File
@@ -279,7 +279,7 @@ impl Interactive for ViewM {
Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT),
Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT),
Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT),
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]),
Stage::Snapshot => {
let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" };
+1 -1
View File
@@ -2,7 +2,7 @@ use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Arc, Vec2};
use serde_json::{Value, json};
use super::*;
use super::{GripMode, stretch_grip};
use crate::Session;
fn h(r: &Value) -> Handle {
+1 -1
View File
@@ -23,7 +23,7 @@ pub(crate) struct Guarded {
}
pub(crate) fn message(p: &(dyn Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
impl Guarded {
+42 -4
View File
@@ -359,6 +359,9 @@ pub struct Settings {
pub gridmajor: u32,
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
pub zoomfactor: u32,
/// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first
/// drawing at startup): `false` (0, the default) imperial, `true` (1) metric.
pub measureinit: bool,
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
pub view_layer_snapshot: bool,
pub dynmode: bool,
@@ -400,6 +403,10 @@ pub struct Settings {
pub pickfirst: bool,
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
pub acadlspasdoc: bool,
/// CMDECHO: kept for routines that switch command echo around their commands; no command reads it yet.
pub cmdecho: bool,
/// FILEDIA: kept for routines that switch file dialogs off around a script; no command reads it yet.
pub filedia: bool,
pub pickadd: bool,
/// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).
/// Drag-only mode (1) is not supported yet.
@@ -473,6 +480,7 @@ impl Default for Settings {
gridunit: Vec2::new(0.5, 0.5),
gridmajor: 5,
zoomfactor: 60,
measureinit: false,
view_layer_snapshot: true,
dynmode: true,
dyndigrip: 31,
@@ -491,6 +499,8 @@ impl Default for Settings {
aperture: 10.0,
pickfirst: true,
acadlspasdoc: false,
cmdecho: true,
filedia: true,
pickadd: true,
pickdrag: true,
pickstyle: 1,
@@ -637,6 +647,9 @@ impl Default for MLeaderOptions {
pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
/// Monotonic count of changes to the open drawings and the active one. A screenshot queued
/// across a switch is rejected even when a later switch returns to the same drawing.
pub doc_epoch: u64,
pub settings: Settings,
pub running: Option<Running>,
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
@@ -717,6 +730,7 @@ impl Session {
Session {
docs: Vec::new(),
active: 0,
doc_epoch: 0,
settings: Settings::default(),
running: None,
in_script: false,
@@ -757,6 +771,7 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
@@ -771,8 +786,14 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
/// Record that which drawing is open or active changed (screenshot binding).
pub fn bump_doc_epoch(&mut self) {
self.doc_epoch = self.doc_epoch.wrapping_add(1);
}
/// A command still waiting for input belongs to the drawing it started in: end it there
/// before another drawing becomes current, so its undo step lands in that drawing.
fn end_running(&mut self) {
@@ -790,6 +811,7 @@ impl Session {
Err(_) => {
self.docs.truncate(n);
self.active = self.active.min(self.docs.len().saturating_sub(1));
self.bump_doc_epoch();
Err(EngineError::Internal("open".into(), "panic".into()))
}
}
@@ -819,7 +841,7 @@ impl Session {
}
/// The space commands act on (model space while working inside a layout viewport).
pub fn space(&self) -> Space {
self.state().map(|s| s.edit_space()).unwrap_or_default()
self.state().map(DocState::edit_space).unwrap_or_default()
}
/// The tab being displayed: Model or a layout, regardless of MSPACE.
pub fn layout_space(&self) -> Space {
@@ -875,13 +897,26 @@ impl Session {
if d.annotative_by_style(&kind) {
common.anno_scales = vec![d.current_anno_scale()];
}
// Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness
// (THICKNESS). RECTANG sets its own, and text never takes it.
if matches!(
kind,
EntityKind::Line(_)
| EntityKind::Arc(_)
| EntityKind::Circle(_)
| EntityKind::Point(_)
| EntityKind::LwPolyline(_)
| EntityKind::Solid(_)
) {
common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0);
}
let h = self.doc_mut()?.add(&space, common, kind)?;
Ok(h)
}
/// World units per pixel at the current view.
pub fn pixel_size(&self) -> f64 {
let v = self.state().map(|s| s.view()).unwrap_or_default();
let v = self.state().map(DocState::view).unwrap_or_default();
v.height / self.viewport_px.1.max(1.0)
}
@@ -901,8 +936,11 @@ impl Session {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
let msg = p
.downcast_ref::<&str>()
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
// Restore the drawing as it was.
if let Some((doc, sel, uid)) = &before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid)
+236
View File
@@ -0,0 +1,236 @@
//! `entmake` defines blocks (#1662): a BLOCK list begins a definition, the entities made after it
//! go into the block instead of the drawing, and an ENDBLK list adds the block and returns its name.
#![allow(clippy::unwrap_used)]
use super::tests::session;
use super::value::{Value, to_text};
use crate::Session;
fn eval(s: &mut Session, code: &str) -> Result<String, String> {
match s.lisp_eval(code, false, true, Vec::<Value>::new()) {
Ok(Some(Ok(v))) => Ok(to_text(&v, true)),
Ok(Some(Err(e))) => Err(e),
Ok(None) => Err("did not finish".into()),
Err(e) => Err(e.to_string()),
}
}
fn ok(s: &mut Session, code: &str) -> String {
match eval(s, code) {
Ok(v) => v,
Err(e) => panic!("{code}: {e}"),
}
}
fn model_len(s: &Session) -> usize {
s.doc().unwrap().model.len()
}
const BLOCK_EB: &str = r#"(entmake '((0 . "BLOCK") (2 . "EB") (70 . 0) (10 5.0 5.0 0.0) (4 . "made by entmake")))"#;
const LINE: &str = r#"(entmake '((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))"#;
const CIRCLE: &str = r#"(entmake '((0 . "CIRCLE") (8 . "PARTS") (10 5.0 5.0 0.0) (40 . 2.5)))"#;
const ENDBLK: &str = r#"(entmake '((0 . "ENDBLK")))"#;
/// The issue's routine: BLOCK, a LINE, ENDBLK, then tblsearch.
#[test]
fn the_issues_routine_defines_the_block() {
let mut s = session();
let code = r#"(list (entmake (quote ((0 . "BLOCK") (2 . "EB") (70 . 0) (10 0.0 0.0 0.0)))) (entmake (quote ((0 . "LINE") (8 . "0") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0)))) (entmake (quote ((0 . "ENDBLK")))) (cdr (assoc 2 (tblsearch "BLOCK" "EB"))))"#;
let v = ok(&mut s, code);
assert!(v.starts_with("(((0 . \"BLOCK\")"), "{v}");
assert!(v.ends_with(" \"EB\" \"EB\")"), "ENDBLK returns the name and tblsearch finds it: {v}");
assert_eq!(model_len(&s), 0, "the LINE went into the block, not model space");
let d = s.doc().unwrap();
assert_eq!(d.block("EB").unwrap().entities.len(), 1);
}
#[test]
fn the_entities_base_point_and_description_are_kept() {
let mut s = session();
assert!(ok(&mut s, BLOCK_EB).starts_with("((0 . \"BLOCK\")"));
assert!(ok(&mut s, LINE).starts_with("((0 . \"LINE\")"), "an entity in a definition returns its list");
ok(&mut s, CIRCLE);
assert_eq!(model_len(&s), 0);
assert!(s.doc().unwrap().block("EB").is_none(), "nothing is added before ENDBLK");
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
let d = s.doc().unwrap();
let b = d.block("EB").unwrap();
assert_eq!((b.base.x, b.base.y, b.description.as_str()), (5.0, 5.0, "made by entmake"));
let hs: Vec<_> = b.entities.iter().map(|e| e.handle).collect();
assert_eq!(hs.len(), 2);
assert!(hs[0].0 != 0 && hs[0] != hs[1], "each entity gets its own handle: {hs:?}");
assert!(d.layers.iter().any(|l| l.name == "PARTS"), "a layer the entities name is created");
// It inserts like any block, and entmake makes entities in the drawing again afterwards.
s.execute("insert", &serde_json::json!({"name": "EB", "at": [100, 0]})).unwrap();
ok(&mut s, LINE);
assert_eq!(model_len(&s), 2);
}
#[test]
fn anonymous_blocks_get_a_name() {
let mut s = session();
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
ok(&mut s, LINE);
let name = ok(&mut s, ENDBLK);
assert!(name.starts_with("\"*U") && name.len() > 4, "{name}");
let d = s.doc().unwrap();
let b = d.block(name.trim_matches('"')).unwrap();
assert!(b.anonymous);
assert_eq!(b.entities.len(), 1);
}
#[test]
fn a_block_of_the_same_name_is_redefined() {
let mut s = session();
for (n, code) in [(1, LINE), (2, CIRCLE)] {
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#);
for _ in 0..n {
ok(&mut s, code);
}
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
}
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2, "the second definition");
// Names are case-insensitive: the existing spelling is kept.
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#);
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
}
#[test]
fn what_cant_be_defined_is_refused() {
let mut s = session();
assert_eq!(ok(&mut s, ENDBLK), "nil", "ENDBLK without a BLOCK");
for bad in [
r#"(entmake '((0 . "BLOCK") (2 . "") (70 . 0) (10 0.0 0.0 0.0)))"#,
r#"(entmake '((0 . "BLOCK") (2 . "A<B") (70 . 0) (10 0.0 0.0 0.0)))"#,
r#"(entmake '((0 . "BLOCK") (2 . "*NOTANON") (70 . 0) (10 0.0 0.0 0.0)))"#,
r#"(entmake '((0 . "BLOCK") (2 . "XR") (70 . 4) (10 0.0 0.0 0.0)))"#,
r#"(entmake '((0 . "BLOCK") (70 . 0) (10 0.0 0.0 0.0)))"#,
] {
assert_eq!(ok(&mut s, bad), "nil", "{bad}");
}
// A BLOCK while one is open is refused; the open one goes on.
ok(&mut s, BLOCK_EB);
assert_eq!(ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "OTHER") (70 . 0) (10 0.0 0.0 0.0)))"#), "nil");
ok(&mut s, LINE);
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
assert!(s.doc().unwrap().block("OTHER").is_none());
// A block that inserts itself isn't added.
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "SELF") (70 . 0) (10 0.0 0.0 0.0)))"#);
ok(&mut s, r#"(entmake '((0 . "INSERT") (2 . "SELF") (10 0.0 0.0 0.0)))"#);
assert_eq!(ok(&mut s, ENDBLK), "nil");
assert!(s.doc().unwrap().block("SELF").is_none());
assert_eq!(model_len(&s), 0);
}
#[test]
fn a_saved_drawing_keeps_the_block() {
let mut s = session();
for code in [BLOCK_EB, LINE, CIRCLE, ENDBLK] {
ok(&mut s, code);
}
s.execute("insert", &serde_json::json!({"name": "EB", "at": [100, 0]})).unwrap();
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let b = read.block("EB").unwrap();
assert_eq!((b.entities.len(), b.base.x, b.base.y), (2, 5.0, 5.0));
assert_eq!(read.model.len(), 1, "the insert");
}
/// Flag 1 makes an anonymous block whatever the name says: a name that isn't `*X` gets `*U`.
#[test]
fn an_anonymous_block_is_always_named_with_a_star() {
let mut s = session();
for (given, prefix) in [("FOO", "*U"), ("*X", "*X"), ("*", "*U")] {
ok(&mut s, &format!(r#"(entmake '((0 . "BLOCK") (2 . "{given}") (70 . 1) (10 0.0 0.0 0.0)))"#));
ok(&mut s, LINE);
let name = ok(&mut s, ENDBLK);
let name = name.trim_matches('"');
assert!(name.starts_with(prefix) && name.len() > prefix.len(), "{given} -> {name}");
assert!(s.doc().unwrap().block(name).unwrap().anonymous);
}
}
/// As BLOCK: a block being edited in place (REFEDIT) isn't redefined until REFCLOSE.
#[test]
fn a_block_being_edited_in_place_is_not_redefined() {
let mut s = session();
for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, LINE, ENDBLK] {
ok(&mut s, code);
}
let ins = s.execute("insert", &serde_json::json!({"name": "R", "at": [0, 0]})).unwrap();
s.execute("refedit", &serde_json::json!({"handle": ins["handle"]})).unwrap();
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "r") (70 . 0) (10 0.0 0.0 0.0)))"#);
ok(&mut s, CIRCLE);
ok(&mut s, CIRCLE);
assert_eq!(ok(&mut s, ENDBLK), "nil");
s.execute("refclose", &serde_json::json!({"option": "discard"})).unwrap();
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 1, "the definition is unchanged");
// After REFCLOSE it can be redefined.
for code in [r#"(entmake '((0 . "BLOCK") (2 . "R") (70 . 0) (10 0.0 0.0 0.0)))"#, CIRCLE, CIRCLE] {
ok(&mut s, code);
}
assert_eq!(ok(&mut s, ENDBLK), "\"R\"");
assert_eq!(s.doc().unwrap().block("R").unwrap().entities.len(), 2);
}
/// A routine that fails drops the definition it began, with or without an *error* handler: the
/// next routine's entities go into the drawing. One that ends normally keeps it open.
#[test]
fn a_failed_routine_drops_its_open_definition() {
let mut s = session();
for handler in [None, Some("(defun *error* (msg) (princ msg))")] {
if let Some(h) = handler {
ok(&mut s, h);
}
let before = model_len(&s);
assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "FAIL") (70 . 0) (10 0.0 0.0 0.0))) (car 1))"#).is_err());
ok(&mut s, LINE);
assert_eq!(model_len(&s), before + 1, "the line is in the drawing ({handler:?})");
assert_eq!(ok(&mut s, ENDBLK), "nil", "nothing is open");
assert!(s.doc().unwrap().block("FAIL").is_none());
}
// A routine that ends normally leaves its definition open for the next one.
ok(&mut s, BLOCK_EB);
ok(&mut s, LINE);
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
}
/// An anonymous block that inserts the name it is about to get (`*U1` in an empty drawing) is a
/// block inserting itself too: refused like a named one.
#[test]
fn an_anonymous_block_that_inserts_itself_is_refused() {
let mut s = session();
assert!(s.doc().unwrap().block("*U1").is_none());
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
ok(&mut s, r#"(entmake '((0 . "INSERT") (2 . "*U1") (10 0.0 0.0 0.0)))"#);
assert_eq!(ok(&mut s, ENDBLK), "nil");
assert!(s.doc().unwrap().block("*U1").is_none());
// The next anonymous block still gets a name and is added.
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "*U") (70 . 1) (10 0.0 0.0 0.0)))"#);
ok(&mut s, LINE);
assert_eq!(ok(&mut s, ENDBLK), "\"*U1\"");
}
/// The layers a definition's entities name are created only when the block is added: a refused
/// definition, or one a failed routine drops, leaves no layer behind.
#[test]
fn a_definition_that_isnt_added_leaves_no_layer() {
let mut s = session();
let has = |s: &Session, n: &str| s.doc().unwrap().layers.iter().any(|l| l.name.eq_ignore_ascii_case(n));
// Refused at ENDBLK: it inserts itself.
ok(&mut s, r#"(entmake '((0 . "BLOCK") (2 . "SELF") (70 . 0) (10 0.0 0.0 0.0)))"#);
ok(&mut s, r#"(entmake '((0 . "LINE") (8 . "ORPHAN1") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))"#);
ok(&mut s, r#"(entmake '((0 . "INSERT") (8 . "ORPHAN1") (2 . "SELF") (10 0.0 0.0 0.0)))"#);
assert!(!has(&s, "ORPHAN1"), "nothing before ENDBLK");
assert_eq!(ok(&mut s, ENDBLK), "nil");
assert!(!has(&s, "ORPHAN1"), "a refused block leaves no layer");
// Dropped: the routine fails.
assert!(eval(&mut s, r#"(progn (entmake '((0 . "BLOCK") (2 . "GONE") (70 . 0) (10 0.0 0.0 0.0))) (entmake '((0 . "LINE") (8 . "ORPHAN2") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0))) (car 1))"#).is_err());
assert!(!has(&s, "ORPHAN2"), "a dropped definition leaves no layer");
// Added: its layers are created.
ok(&mut s, BLOCK_EB);
ok(&mut s, CIRCLE);
assert!(!has(&s, "PARTS"));
assert_eq!(ok(&mut s, ENDBLK), "\"EB\"");
assert!(has(&s, "PARTS"), "the block's layer exists once it is added");
}
+8 -6
View File
@@ -5,6 +5,8 @@
//! Errors use AutoLISP's wording (`bad argument type: numberp: nil`, `too few arguments`,
//! `divide by zero`), since routines and their `*error*` handlers match on it.
use std::cmp::Ordering;
use std::ffi::OsStr;
use std::sync::Arc;
use super::eval::{Ctx, Flow, Special};
@@ -335,16 +337,16 @@ fn num_ne(a: &[Value]) -> R {
eq_chain(a, false)
}
fn lt(a: &[Value]) -> R {
chain(a, |o| o.is_lt())
chain(a, Ordering::is_lt)
}
fn gt(a: &[Value]) -> R {
chain(a, |o| o.is_gt())
chain(a, Ordering::is_gt)
}
fn le(a: &[Value]) -> R {
chain(a, |o| o.is_le())
chain(a, Ordering::is_le)
}
fn ge(a: &[Value]) -> R {
chain(a, |o| o.is_ge())
chain(a, Ordering::is_ge)
}
fn zerop(a: &[Value]) -> R {
@@ -658,12 +660,12 @@ fn vl_list_to_string(a: &[Value]) -> R {
fn vl_filename_base(a: &[Value]) -> R {
arity(a, 1, 1)?;
let s = string(&a[0])?;
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(|x| x.to_str()).unwrap_or("")))
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or("")))
}
fn vl_filename_extension(a: &[Value]) -> R {
arity(a, 1, 1)?;
let s = string(&a[0])?;
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(|x| x.to_str()).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
}
fn vl_filename_directory(a: &[Value]) -> R {
arity(a, 1, 1)?;
+92 -1
View File
@@ -357,10 +357,25 @@ fn entity_from_list(ctx: &Ctx<'_>, list: &Value) -> Result<(String, cadcraft_doc
pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 0, 1)?;
let Some(list) = a.first().filter(|v| !v.is_nil()) else { return Ok(Flow::Value(Value::Nil)) };
// A block definition is a BLOCK list, its entities, then an ENDBLK list (#1662).
match list_to_groups(list) {
Ok((k, tags)) if k == "BLOCK" => return Ok(Flow::Value(begin_block(ctx, list, &tags))),
Ok((k, _)) if k == "ENDBLK" => return Ok(Flow::Value(end_block(ctx))),
_ => {}
}
let (_, common, kind) = match entity_from_list(ctx, list) {
Ok(x) => x,
Err(_) => return Ok(Flow::Value(Value::Nil)),
};
if let Some((_, ents)) = ctx.env.pending_block.as_mut() {
// Into the block being defined; it is added to the drawing, with handles and the layers
// its entities name, at ENDBLK (a refused or dropped definition leaves nothing behind).
if ents.len() >= MAX_PENDING_BLOCK_ENTITIES {
return Ok(Flow::Value(Value::Nil));
}
ents.push(Entity { handle: Handle(0), common, kind });
return Ok(Flow::Value(list.clone()));
}
let space = ctx.s.space();
let Ok(d) = ctx.s.doc_mut() else { return Ok(Flow::Value(Value::Nil)) };
// A layer entmake names that doesn't exist yet is created.
@@ -374,6 +389,75 @@ pub fn entmake(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
}
}
/// `entmake` of a BLOCK list (2 name, 70 flags, 10 base point, 4 description): begin a block
/// definition. Flag 1 makes an anonymous block (`*U…`, named at ENDBLK); xrefs (flag 4) can't be
/// made this way. nil, and the definition already begun kept, when one is open.
fn begin_block(ctx: &mut Ctx<'_>, list: &Value, tags: &[Tag]) -> Value {
if ctx.env.pending_block.is_some() {
return Value::Nil;
}
let group = |code: i32| tags.iter().find(|t| t.code == code);
let flags = group(70).map(Tag::i64).unwrap_or(0);
let name = group(2).map(|t| t.str().trim().to_string()).unwrap_or_default();
let anonymous = flags & 1 != 0;
if flags & 4 != 0 || !(anonymous && name.starts_with('*') || crate::cmd::blocks::valid_block_name(&name)) {
return Value::Nil;
}
let mut b = cadcraft_doc::Block::new(&name);
b.anonymous = anonymous;
b.base = cadcraft_geom::Vec3::new(
group(10).map(Tag::f64).unwrap_or(0.0),
group(20).map(Tag::f64).unwrap_or(0.0),
group(30).map(Tag::f64).unwrap_or(0.0),
);
if !b.base.is_finite() {
return Value::Nil;
}
b.description = group(4).map(Tag::str).unwrap_or_default();
ctx.env.pending_block = Some((b, Vec::new()));
list.clone()
}
/// The most entities one `entmake` block definition takes (a runaway loop can't fill memory).
const MAX_PENDING_BLOCK_ENTITIES: usize = 1_000_000;
/// `entmake` of an ENDBLK list: add the block begun to the drawing (redefining one of the same
/// name) and return its name, generated for an anonymous block. nil without one begun, or when
/// its entities reference the block itself.
fn end_block(ctx: &mut Ctx<'_>) -> Value {
let Some((mut b, ents)) = ctx.env.pending_block.take() else { return Value::Nil };
let Ok(d) = ctx.s.doc_mut() else { return Value::Nil };
if b.anonymous {
// Its kind letter (`*U`, `*X`…) is kept; a name that isn't one gets `*U`.
let prefix: String = b.name.chars().take(2).collect();
let prefix = if prefix.chars().count() == 2 && prefix.starts_with('*') { prefix.as_str() } else { "*U" };
b.name = d.anonymous_block_name(prefix);
} else {
// Block names are case-insensitive: a redefinition keeps the existing name.
if let Some(existing) = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&b.name)) {
b.name = existing.clone();
}
// As BLOCK: a block being edited in place isn't redefined until REFCLOSE.
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(&b.name)) {
return Value::Nil;
}
}
// Named or anonymous (a routine can know the name an anonymous block will get), a block
// that inserts itself isn't added.
if crate::cmd::blocks::references_block(d, &ents, &b.name) {
return Value::Nil;
}
for mut e in ents {
d.ensure_layer(&e.common.layer);
e.handle = d.new_handle();
b.entities.push(e);
}
let name = b.name.clone();
d.blocks.insert(name.clone(), Arc::new(b));
ctx.s.touch();
Value::str(&name)
}
pub fn entmod(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 1)?;
let list = &a[0];
@@ -985,7 +1069,14 @@ pub fn distof(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
pub fn angtof(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 1, 2)?;
let s = string(&a[0])?;
Ok(Flow::Value(ctx.s.angle_settings().direction(&s).map(Value::Real).unwrap_or_default()))
let mut settings = ctx.s.angle_settings();
// AUNITS 0..=4; any other units argument is an error in AutoCAD, nil here.
match a.get(1) {
None | Some(Value::Nil) => {}
Some(Value::Int(u)) if (0..=4).contains(u) => settings.aunits = i64::from(*u),
Some(_) => return Ok(Flow::Value(Value::Nil)),
}
Ok(Flow::Value(settings.direction(&s).map(Value::Real).unwrap_or_default()))
}
/// `(trans pt from to [disp])`: points are in the world system throughout (no UCS yet).
+2 -1
View File
@@ -167,6 +167,7 @@ impl GetRequest {
let t = if spaces { t } else { t.split(' ').next().unwrap_or("") };
Ok(Value::str(t))
}
GetKind::Kword if any => Ok(Value::str(t)),
GetKind::Kword => Err("Invalid option keyword.".into()),
GetKind::Int => match t.trim().parse::<i32>() {
Ok(i) if (-32768..=32767).contains(&i) => self.check_number(f64::from(i), true),
@@ -260,7 +261,7 @@ fn keyword(keywords: &[String], t: &str) -> Option<String> {
impl Session {
/// ANGBASE (radians).
fn lisp_angbase(&self) -> f64 {
crate::sysvars::get(self, "ANGBASE").and_then(|v| v.as_f64()).unwrap_or(0.0)
self.doc().map(|d| d.header.f64("ANGBASE", 0.0)).unwrap_or(0.0)
}
}
+29 -2
View File
@@ -58,6 +58,9 @@ pub struct Env {
pub order: Option<(usize, Arc<Vec<Handle>>)>,
/// `tblnext`'s place in each table.
pub tblnext: HashMap<String, usize>,
/// A block definition `entmake` has begun (a BLOCK list) and not yet ended (ENDBLK): the
/// entities made meanwhile go into it, numbered when it ends.
pub pending_block: Option<(cadcraft_doc::Block, Vec<cadcraft_doc::Entity>)>,
/// The command history's length after LISP last printed a line: when something else wrote since
/// (a prompt, a command's echo), a leading newline only ends that line.
pub printed_at: Option<usize>,
@@ -75,6 +78,7 @@ impl Default for Env {
deleted: HashMap::new(),
order: None,
tblnext: HashMap::new(),
pending_block: None,
printed_at: None,
};
builtins::install(&mut e);
@@ -440,6 +444,12 @@ impl Session {
/// A LISP error: run `*error*` when the routine defined one, else print it.
fn lisp_error(&mut self, e: LispError) {
let Some(mut run) = self.lisp.run.take() else { return };
// A block definition the failed routine began is dropped: a later routine's entities
// would otherwise go into it instead of the drawing (#1662). A definition still spans
// runs that end normally.
if let Some(env) = self.lisp.envs.get_mut(&run.uid) {
env.pending_block = None;
}
// The command a failed routine left running is cancelled.
if self.running.is_some() {
self.cancel();
@@ -567,6 +577,20 @@ impl Session {
self.lisp.active
}
/// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted.
pub fn lisp_commands(&self) -> Vec<String> {
let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() };
let Some(env) = self.lisp.envs.get(&uid) else { return Vec::new() };
let mut names: Vec<String> = env
.globals
.iter()
.filter(|(name, f)| name.name().starts_with("C:") && matches!(f, Value::Func(_) | Value::Subr(_)))
.filter_map(|(name, _)| name.name().strip_prefix("C:").filter(|n| !n.is_empty()).map(str::to_string))
.collect();
names.sort();
names
}
/// A `c:` function for a command name typed at the command line.
pub(crate) fn lisp_command(&self, name: &str) -> Option<Value> {
if self.lisp.active {
@@ -600,7 +624,7 @@ impl Session {
self.lisp.files.insert(file_key(name), text.to_string());
let r = self.lisp_eval(text, false, blocking, Vec::new())?;
if !matches!(r, Some(Err(_))) {
let shown = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_string();
let shown = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_string();
self.echo(format!("{shown} successfully loaded."));
}
Ok(r)
@@ -608,9 +632,12 @@ impl Session {
}
fn file_key(name: &str) -> String {
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(|n| n.to_str()).unwrap_or(name).to_ascii_lowercase();
let base = std::path::Path::new(&name.replace('\\', "/")).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name).to_ascii_lowercase();
base.strip_suffix(".lsp").map(str::to_string).unwrap_or(base)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod block_tests;
+110 -1
View File
@@ -4,7 +4,7 @@ use super::value::{Value, to_text};
use crate::Session;
use crate::cmd::file::{EntityCodec, set_entity_codec};
fn session() -> Session {
pub(super) fn session() -> Session {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
Session::new()
}
@@ -437,6 +437,24 @@ fn tables_and_variables() {
assert_eq!(ok(&mut s, "(getvar \"NOSUCHVAR\")"), "nil");
}
#[test]
fn getvar_drawing_and_session_variables() {
let mut s = session();
assert_eq!(ok(&mut s, "(list (getvar \"DWGPREFIX\") (getvar \"DWGTITLED\"))"), "(\"\" 0)");
assert_eq!(ok(&mut s, "(list (getvar \"FILEDIA\") (getvar \"CMDECHO\") (getvar \"TILEMODE\"))"), "(1 1 1)");
assert_eq!(ok(&mut s, "(setvar \"FILEDIA\" 0) (setvar \"CMDECHO\" 0) (getvar \"FILEDIA\")"), "0");
// Both belong to the session, so they survive a switch to another drawing.
s.new_drawing(true);
assert_eq!(ok(&mut s, "(list (getvar \"FILEDIA\") (getvar \"CMDECHO\"))"), "(0 0)");
assert_eq!(ok(&mut s, "(getvar \"CMDACTIVE\")"), "0");
let dir = std::env::temp_dir();
let file = dir.join("lisp_getvar_test.dxf");
s.state_mut().unwrap().path = Some(file.to_string_lossy().into_owned());
let want = format!("{}{}", dir.to_string_lossy().trim_end_matches(std::path::MAIN_SEPARATOR), std::path::MAIN_SEPARATOR);
assert_eq!(crate::sysvars::get(&s, "DWGPREFIX"), Some(serde_json::json!(want)));
assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1");
}
#[test]
fn input_functions_take_supplied_answers() {
let mut s = session();
@@ -464,6 +482,13 @@ fn interactive_routine_waits_for_the_user() {
s.cmdline("(progn (initget 1 \"Yes No\") (setq ans (getkword \"\\nSure? [Yes/No]: \")))").unwrap();
s.cmdline("n").unwrap();
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"No\"");
// Bit 128 lets getkword return arbitrary text; a keyword still wins.
s.cmdline("(progn (initget 128 \"SElect CUrrent\") (setq ans (getkword \"\\nOption: \")))").unwrap();
s.cmdline("banana").unwrap();
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"banana\"");
s.cmdline("(progn (initget 128 \"SElect CUrrent\") (setq ans (getkword \"\\nOption: \")))").unwrap();
s.cmdline("cu").unwrap();
assert_eq!(to_text(&s.lisp_var("ANS"), true), "\"CUrrent\"");
s.cmdline("(setq d (getdist '(0 0)))").unwrap();
s.cmdline("3,4").unwrap();
assert_eq!(to_text(&s.lisp_var("D"), true), "5.0");
@@ -479,6 +504,24 @@ fn interactive_routine_waits_for_the_user() {
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Circle(c) if c.center.x == 50.0 && c.radius == 2.0)));
}
#[test]
fn getangle_is_relative_to_angbase() {
let mut s = session();
s.cmdline("(setvar \"ANGBASE\" 90)").unwrap();
// A picked point at 180° in world terms is 90° from ANGBASE.
s.cmdline("(setq a (getangle '(0 0)))").unwrap();
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
assert_eq!(to_text(&s.lisp_var("A"), true), "1.5708");
// getorient ignores ANGBASE.
s.cmdline("(setq o (getorient '(0 0)))").unwrap();
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
assert_eq!(to_text(&s.lisp_var("O"), true), "3.14159");
// A typed angle is already measured from ANGBASE.
s.cmdline("(setq t2 (getangle))").unwrap();
s.cmdline("180").unwrap();
assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159");
}
#[test]
fn appload_and_load() {
let mut s = session();
@@ -499,6 +542,63 @@ fn appload_and_load() {
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
}
#[test]
fn appload_reads_a_lsp_path_and_reports_its_commands() {
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tools.LSP");
std::fs::write(&path, "(defun c:hi () (princ \"hi\") (princ)) (defun c:bye () (princ))").unwrap();
let mut s = session();
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("error").is_none(), "{r}");
assert_eq!(r["commands"], serde_json::json!(["BYE", "HI"]));
assert!(s.log.iter().any(|l| l == "Tools.LSP successfully loaded."));
s.cmdline("HI").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("hi"));
// Only `.lsp` files are APPLOADed, whatever the case of the extension.
let txt = dir.join("notes.txt");
std::fs::write(&txt, "(defun c:no () (princ))").unwrap();
let e = s.execute("appload", &serde_json::json!({"path": txt.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains(".lsp"), "{e}");
// A missing file and a file that fails to parse are ordinary errors.
let missing = dir.join("missing.lsp");
let e = s.execute("appload", &serde_json::json!({"path": missing.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("can't read"), "{e}");
let broken = dir.join("broken.lsp");
std::fs::write(&broken, "(defun c:oops (").unwrap();
let e = s.execute("appload", &serde_json::json!({"path": broken.to_string_lossy()})).unwrap_err().to_string();
assert!(!e.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn appload_keeps_the_whitespace_of_a_relative_path() {
/// A folder in the test's current directory, removed even when an assertion fails.
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// A JSON call's path is read as it is given: a relative path whose folder starts with a
// space names that folder, not the one its trimmed name would.
let dir = TempDir(std::path::PathBuf::from(format!(" cadcraft-appload-space-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&dir.0);
std::fs::create_dir_all(&dir.0).unwrap();
let path = dir.0.join("tools.lsp");
std::fs::write(&path, "(setq space-loaded 1)").unwrap();
let mut s = session();
let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("error").is_none(), "{r}");
assert_eq!(ok(&mut s, "space-loaded"), "1");
// A path of blanks only is still refused, not read as "".
for blank in ["", " ", "\t "] {
let e = s.execute("appload", &serde_json::json!({"path": blank})).unwrap_err().to_string();
assert!(e.contains("`path`"), "{blank:?}: {e}");
}
}
#[test]
fn hostile_lisp_never_panics() {
let mut s = session();
@@ -746,3 +846,12 @@ fn a_panic_inside_a_routine_ends_it_with_an_error() {
assert!(s.log.iter().any(|l| l.contains("internal error")), "{:?}", s.log);
assert_eq!(ok(&mut s, "(+ 1 2)"), "3");
}
#[test]
fn angtof_reads_the_string_in_the_given_units() {
let mut s = Session::new();
assert_eq!(ok(&mut s, "(angtof \"1.5\" 3)"), "1.5");
assert_eq!(ok(&mut s, "(angtof \"180\" 0)"), "3.14159");
assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");
assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil");
}
+1 -1
View File
@@ -101,7 +101,7 @@ impl Prompt {
self
}
pub fn kw(mut self, k: &[&str]) -> Self {
self.keywords = k.iter().map(|s| s.to_string()).collect();
self.keywords = k.iter().map(ToString::to_string).collect();
self
}
pub fn base(mut self, p: Vec2) -> Self {
+1 -1
View File
@@ -434,7 +434,7 @@ impl SelectMode {
};
match last {
None => Prompt::new(first, Accept::POINT),
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p),
Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),
Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),
}
}
+167 -18
View File
@@ -78,12 +78,53 @@ impl SnapHit {
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SnapCurve {
/// A circle, or the circle an arc lies on.
/// A circle, or the circle an arc lies on; an arc's sweep is carried by [`SnapCurve::Arc`].
Circle { center: Vec2, radius: f64 },
/// An arc: the points of `Circle { center, radius }` swept CCW from `start` to `end` (radians).
Arc { center: Vec2, radius: f64, start: f64, end: f64 },
/// The infinite line through `a` and `b` (a line, xline or ray).
Line { a: Vec2, b: Vec2 },
}
impl SnapCurve {
/// Whether `p` lies on the drawn curve: always on a circle or a line, and only within the
/// sweep on an arc. Tangent and perpendicular solutions on an arc's circle that fall off the
/// arc are filtered out with this, so they never snap into the gap.
pub fn contains(&self, p: Vec2) -> bool {
match self {
// A zero sweep is how a full circle is stored (see `fix_circle`): the whole circle.
SnapCurve::Arc { center, radius, start, end } => {
(*start - *end).abs() < 1e-12 || cadcraft_geom::Arc::new(*center, *radius, *start, *end).contains_angle(center.angle_to(p))
}
_ => true,
}
}
/// Points on the curve whose tangent passes through `p` (a circle's or an arc's).
fn tangents(&self, p: Vec2) -> Vec<Vec2> {
match self {
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => {
Circle::new(*center, *radius).tangent_points(p).into_iter().filter(|t| self.contains(*t)).collect()
}
SnapCurve::Line { .. } => Vec::new(),
}
}
/// Points on the curve whose perpendicular passes through `p`; `picked` is where the curve
/// was picked, kept when `p` is the center of a circle or arc (every radius is perpendicular).
fn perpendiculars(&self, p: Vec2, picked: Vec2) -> Vec<Vec2> {
match self {
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => {
let u = (p - *center).normalized();
if u == Vec2::ZERO {
vec![Circle::new(*center, *radius).closest(picked)]
} else {
[*center + u * *radius, *center - u * *radius].into_iter().filter(|q| self.contains(*q)).collect()
}
}
SnapCurve::Line { a, b } => line_dir(*a, *b).map(|_| vec![cadcraft_geom::Line::new(*a, *b).project(p)]).unwrap_or_default(),
}
}
}
/// A deferred tangent ([`mode::TAN`]) or perpendicular ([`mode::PER`]) snap, picked while there
/// is no base point yet (the first point of a line). The real point depends on the other end of
/// the line, so it is resolved later with [`resolve_deferred`].
@@ -107,6 +148,18 @@ pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Op
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 })
}
SnapCurve::Arc { center, radius, start, end } => {
let n = dir + std::f64::consts::FRAC_PI_2;
let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius;
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
let on = cadcraft_geom::Arc::new(*center, *radius, *start, *end);
let ok = |c: Vec2| on.contains_angle(center.angle_to(c));
let (c1, c2) = (ok(c1).then_some(c1), ok(c2).then_some(c2));
match (c1, c2) {
(Some(a), Some(b)) => Some(if a.dist(hint) < b.dist(hint) { a } else { b }),
(a, b) => a.or(b),
}
}
_ => None,
}
}
@@ -423,7 +476,12 @@ fn finish(
if osmode & mode::PER != 0 {
match p {
Prim::Seg(Segment::Line(l)) => cands.push((mode::PER, l.project(bp))),
Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))),
// Both antipodes, filtered to the arc's sweep: a perpendicular in the gap is
// not offered, and the far side is still found.
Prim::Seg(Segment::Arc { arc, .. }) => {
let curve = SnapCurve::Arc { center: arc.center, radius: arc.radius, start: arc.start, end: arc.end };
cands.extend(curve.perpendiculars(bp, bp).into_iter().map(|q| (mode::PER, q)));
}
Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))),
Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))),
Prim::Ellipse(e) => cands.extend(curves::ellipse_perpendiculars(e, bp).into_iter().map(|q| (mode::PER, q))),
@@ -433,7 +491,12 @@ fn finish(
}
if osmode & mode::TAN != 0 {
let pts = match p {
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius).tangent_points(bp),
// Only the tangent points that lie on the arc itself (not its circle).
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius)
.tangent_points(bp)
.into_iter()
.filter(|t| arc.contains_angle(arc.center.angle_to(*t)))
.collect(),
Prim::Circle(c) => c.tangent_points(bp),
Prim::Ellipse(e) => curves::ellipse_tangents(e, bp),
Prim::Spline(s) => curves::spline_tangents(s, bp),
@@ -808,7 +871,7 @@ fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip:
for p in prims {
let curve = match p {
Prim::Circle(c) => SnapCurve::Circle { center: c.center, radius: c.radius },
Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Circle { center: arc.center, radius: arc.radius },
Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Arc { center: arc.center, radius: arc.radius, start: arc.start, end: arc.end },
Prim::Seg(Segment::Line(l)) => SnapCurve::Line { a: l.a, b: l.b },
Prim::Infinite { base, dir, .. } => SnapCurve::Line { a: *base, b: *base + *dir },
_ => continue,
@@ -820,6 +883,12 @@ fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip:
SnapCurve::Circle { center, radius } if osmode & mode::PER != 0 => {
(mode::PER, "Deferred Perpendicular", Circle::new(center, radius).closest(cursor))
}
SnapCurve::Arc { center, radius, start, end } if osmode & mode::TAN != 0 => {
(mode::TAN, "Deferred Tangent", cadcraft_geom::Arc::new(center, radius, start, end).closest(cursor))
}
SnapCurve::Arc { center, radius, start, end } if osmode & mode::PER != 0 => {
(mode::PER, "Deferred Perpendicular", cadcraft_geom::Arc::new(center, radius, start, end).closest(cursor))
}
SnapCurve::Line { a, b } if osmode & mode::PER != 0 => {
// Pick on the drawn extent: a segment's own span, an xline/ray anywhere.
let l = cadcraft_geom::Line::new(a, b);
@@ -848,18 +917,9 @@ pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> {
if !p.is_finite() {
return None;
}
let cands: Vec<Vec2> = match (d.mode, d.curve) {
(mode::TAN, SnapCurve::Circle { center, radius }) => Circle::new(center, radius).tangent_points(p),
(mode::PER, SnapCurve::Circle { center, radius }) => {
let u = (p - center).normalized();
if u == Vec2::ZERO {
// Every radius is perpendicular: keep the picked point.
vec![Circle::new(center, radius).closest(d.at)]
} else {
vec![center + u * radius, center - u * radius]
}
}
(mode::PER, SnapCurve::Line { a, b }) => line_dir(a, b).map(|_| vec![cadcraft_geom::Line::new(a, b).project(p)]).unwrap_or_default(),
let cands: Vec<Vec2> = match d.mode {
mode::TAN => d.curve.tangents(p),
mode::PER => d.curve.perpendiculars(p, d.at),
_ => Vec::new(),
};
pick_best(cands.into_iter().map(|t| (t, p)), d.at, None)
@@ -875,7 +935,12 @@ pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> {
/// caller retries swapped).
fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option<Vec<(Vec2, Vec2)>> {
use SnapCurve::{Circle as C, Line as L};
Some(match ((d1.mode, d1.curve), (d2.mode, d2.curve)) {
// The math below is on the circle an arc lies on; the sweep is checked afterwards.
let circle_of = |c: SnapCurve| match c {
SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => C { center, radius },
other => other,
};
let pairs = match ((d1.mode, circle_of(d1.curve)), (d2.mode, circle_of(d2.curve))) {
((mode::TAN, C { center: c1, radius: r1 }), (mode::TAN, C { center: c2, radius: r2 })) => {
Circle::new(c1, r1).common_tangents(&Circle::new(c2, r2))
}
@@ -923,7 +988,10 @@ fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option<Vec<(Vec2, Vec2)>> {
vec![(p, cadcraft_geom::Line::new(a2, b2).project(p))]
}
_ => return None,
})
};
// A solution on an arc's circle that falls off the arc is no solution: the line would touch
// the circle, not the arc that was picked.
Some(pairs.into_iter().filter(|(p, q)| d1.curve.contains(*p) && d2.curve.contains(*q)).collect())
}
/// Unit direction of the line through `a` and `b`, if it has one.
@@ -1185,4 +1253,85 @@ mod tests {
let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::X };
assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none());
}
/// A drawing with one arc: center (20,0), radius 2, swept CCW from 0° to 90° (the first
/// quadrant only, so the rest of its circle is the gap).
fn arc_drawing() -> Drawing {
let mut d = Drawing::new_imperial();
d.add(
&Space::Model,
Common::default(),
EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 }),
)
.unwrap();
d
}
fn on_arc(p: Vec2) -> bool {
let a = Vec2::new(20.0, 0.0).angle_to(p);
cadcraft_geom::angle_in_sweep(a, 0.0, std::f64::consts::FRAC_PI_2) && (p.dist(Vec2::new(20.0, 0.0)) - 2.0).abs() < 1e-9
}
#[test]
fn arc_tangent_only_on_the_arc_not_its_circle() {
let d = arc_drawing();
// From (20,5), the circle's tangents touch at about ±23.6° from the vertical through the
// center: one lands at 90+23.6 = 113.6° (the gap), the other at 90-23.6 = 66.4° (on the
// arc). Only the one on the arc may snap.
let base = Some(Vec2::new(20.0, 5.0));
let t = osnap(&d, &Space::Model, Vec2::new(20.0 + 2.0, 0.0), 8.0, mode::TAN, base, false).expect("the arc's tangent");
assert!(on_arc(t.point), "tangent must lie on the arc, got {:?}", t.point);
// The other solution is on the circle but off the arc, so it is not offered.
let off = Vec2::polar(Vec2::new(20.0, 0.0), 2.0, 113.6f64.to_radians());
assert!(!on_arc(off));
let none = osnap(&d, &Space::Model, off, 0.05, mode::TAN, base, false);
assert!(none.is_none(), "a tangent in the arc's gap must not snap, got {none:?}");
}
#[test]
fn arc_perpendicular_clamps_to_the_arc() {
let d = arc_drawing();
// The foot of the perpendicular from below lands at 270°, in the gap. The opposite
// antipode at (20,2) is on the arc. The cursor is 5 units away from it, so the aperture
// is a little larger to keep the distance test off the boundary.
let h = osnap(&d, &Space::Model, Vec2::new(20.0, -3.0), 5.5, mode::PER, Some(Vec2::new(20.0, -6.0)), false)
.expect("the perpendicular on the arc");
assert!(on_arc(h.point), "perpendicular must stay on the arc, got {:?}", h.point);
assert!(h.point.near(Vec2::new(20.0, 2.0), 1e-9), "the opposite antipode on the arc, got {:?}", h.point);
}
#[test]
fn arc_deferred_tangent_keeps_its_sweep() {
let d = arc_drawing();
let at = Vec2::polar(Vec2::new(20.0, 0.0), 2.0, 45f64.to_radians());
let h = osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, true).expect("deferred tangent on the arc");
assert_eq!((h.mode, h.name), (mode::TAN, "Deferred Tangent"));
let df = h.deferred.unwrap();
// The sweep travels with the curve, so a later solve stays on the arc.
assert!(matches!(df.curve, SnapCurve::Arc { .. }), "the arc's sweep must be kept: {:?}", df.curve);
// A deferred tangent solves to a point on the arc, not into its gap.
let solved = resolve_deferred(LineEnd::Deferred(df), pt(10.0, 10.0)).expect("a tangent from (10,10)");
// "In the order given": the deferred end solves first.
assert!(on_arc(solved.0), "resolved tangent must lie on the arc, got {:?}", solved.0);
}
#[test]
fn deferred_tangent_pairs_with_arcs() {
// The arc is the top half of a circle at (0,0) r=5.
let arc = SnapCurve::Arc { center: Vec2::new(0.0, 0.0), radius: 5.0, start: 0.0, end: std::f64::consts::PI };
// The circle is at (10,0) r=5.
let circle = SnapCurve::Circle { center: Vec2::new(10.0, 0.0), radius: 5.0 };
let d_arc = Deferred { mode: mode::TAN, curve: arc, at: Vec2::new(0.0, 5.0) };
let d_circle = Deferred { mode: mode::TAN, curve: circle, at: Vec2::new(10.0, 5.0) };
// Common tangents of the carrier circles: two outer (y=5 and y=-5) and two inner.
// The y=-5 tangent touches the arc's circle at (0,-5), which is in the gap.
// The y=5 tangent touches at (0,5), on the arc.
// The inner tangents touch the left circle at angles that are also not on the top half.
// Only the y=5 solution survives the arc's sweep.
let solved = resolve_deferred(LineEnd::Deferred(d_arc), LineEnd::Deferred(d_circle)).unwrap();
assert!(solved.0.near(Vec2::new(0.0, 5.0), 1e-9), "expected the belt tangent on the top arc, got {:?}", solved.0);
assert!(solved.1.near(Vec2::new(10.0, 5.0), 1e-9));
}
}
+7
View File
@@ -71,6 +71,13 @@ impl<'a> Projection<'a> {
}
SnapCurve::Circle { center, radius }
}
SnapCurve::Arc { center, radius, start, end } => {
let (center, radius) = (self.to_paper(center), radius * self.scale);
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
return None;
}
SnapCurve::Arc { center, radius, start, end }
}
SnapCurve::Line { a, b } => {
let (a, b) = (self.to_paper(a), self.to_paper(b));
if !(a.is_finite() && b.is_finite()) {
+58 -2
View File
@@ -55,6 +55,8 @@ const SESSION_VARS: &[&str] = &[
"APERTURE",
"PICKFIRST",
"ACADLSPASDOC",
"CMDECHO",
"FILEDIA",
"PICKADD",
"PICKDRAG",
"PICKSTYLE",
@@ -62,6 +64,7 @@ const SESSION_VARS: &[&str] = &[
"CURSORSIZE",
"MAXARRAY",
"ZOOMFACTOR",
"MEASUREINIT",
"LASTPOINT",
"FONTALT",
"FONTFALLBACK",
@@ -129,6 +132,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"APERTURE" => json!(st.aperture),
"PICKFIRST" => json!(i32::from(st.pickfirst)),
"ACADLSPASDOC" => json!(i32::from(st.acadlspasdoc)),
"CMDECHO" => json!(i32::from(st.cmdecho)),
"FILEDIA" => json!(i32::from(st.filedia)),
"PICKADD" => json!(i32::from(st.pickadd)),
"PICKDRAG" => json!(i32::from(st.pickdrag) * 2),
"PICKSTYLE" => json!(st.pickstyle),
@@ -136,6 +141,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"CURSORSIZE" => json!(st.cursorsize),
"MAXARRAY" => json!(st.maxarray),
"ZOOMFACTOR" => json!(st.zoomfactor),
"MEASUREINIT" => json!(i32::from(st.measureinit)),
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
@@ -149,6 +155,12 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
// The drawing's folder with a trailing separator ("" while it has no file); DWGTITLED is 1 once it has one.
"DWGPREFIX" => json!(s.state().ok().and_then(|d| d.path.as_deref()).map(dwg_prefix).unwrap_or_default()),
"DWGTITLED" => json!(s.state().map(|d| i32::from(d.path.is_some())).unwrap_or(0)),
"CMDACTIVE" => json!(i32::from(s.running.is_some())),
"TILEMODE" => json!(i32::from(s.state().is_ok_and(|d| d.space.layout().is_none()))),
"PLATFORM" => json!(std::env::consts::OS),
"BLOCKEDITOR" => json!(i32::from(s.state().is_ok_and(|d| d.block_edit.is_some()))),
// The template NEW/QNEW start from (a profile setting, like FONTALT; "" = none).
"QNEWTEMPLATE" => json!(crate::cmd::file::qnew_template()),
@@ -186,6 +198,21 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
Some(v)
}
/// DWGPREFIX: the folder of the drawing file, made absolute, with a trailing separator.
fn dwg_prefix(path: &str) -> String {
let full = std::path::absolute(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
match full.parent() {
Some(dir) => {
let mut text = dir.to_string_lossy().into_owned();
if !text.ends_with(std::path::MAIN_SEPARATOR) {
text.push(std::path::MAIN_SEPARATOR);
}
text
}
None => String::new(),
}
}
fn as_bool(v: &Value) -> Option<bool> {
v.as_bool().or_else(|| v.as_i64().map(|i| i != 0)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()).map(|i| i != 0))
}
@@ -205,8 +232,25 @@ fn as_pt(v: &Value) -> Option<cadcraft_geom::Vec2> {
}
/// Variables `get` reports from session or drawing state that `set` can't change.
const READ_ONLY: &[&str] =
&["CMDNAMES", "DWGNAME", "DBMOD", "BLOCKEDITOR", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX", "UNDOCTL", "UNDOMARKS"];
const READ_ONLY: &[&str] = &[
"CMDNAMES",
"DWGNAME",
"DWGPREFIX",
"DWGTITLED",
"CMDACTIVE",
"TILEMODE",
"PLATFORM",
"DBMOD",
"BLOCKEDITOR",
"CTAB",
"LASTPOINT",
"VIEWCTR",
"VIEWSIZE",
"EXTMIN",
"EXTMAX",
"UNDOCTL",
"UNDOMARKS",
];
pub fn is_read_only(name: &str) -> bool {
READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) || table::is_read_only(&name.trim().to_ascii_uppercase())
@@ -234,6 +278,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
let st = &mut s.settings;
match n.as_str() {
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
// The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour.
"CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?,
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
"ANNOAUTOSCALE" => st.annoautoscale = as_i(v).filter(|i| (-4..=4).contains(i)).ok_or_else(bad)? > 0,
// A scale of the drawing's list; ANNOAUTOSCALE adds it to the annotative objects.
@@ -302,6 +348,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
"APERTURE" => st.aperture = as_f64(v).ok_or_else(bad)?.clamp(1.0, 50.0),
"PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?,
"ACADLSPASDOC" => st.acadlspasdoc = as_bool(v).ok_or_else(bad)?,
"CMDECHO" => st.cmdecho = small(v, 1).ok_or_else(bad)? == 1,
"FILEDIA" => st.filedia = small(v, 1).ok_or_else(bad)? == 1,
"PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,
"PICKDRAG" => st.pickdrag = as_i(v).filter(|b| matches!(b, 0 | 2)).map(|b| b == 2).ok_or_else(bad)?,
"PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,
@@ -309,6 +357,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
"CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
"ZOOMFACTOR" => st.zoomfactor = as_i(v).filter(|z| (3..=100).contains(z)).ok_or_else(bad)? as u32,
"MEASUREINIT" => st.measureinit = small(v, 1).ok_or_else(bad)? == 1,
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
"SAVETIME" => crate::autosave::set_savetime(
@@ -330,6 +379,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
return r;
}
let d = s.doc_mut()?;
// A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE.
if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) {
let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default();
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
return Err(bad());
}
}
let val = match v {
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?),
+74
View File
@@ -510,6 +510,22 @@ fn properties_set_transparency() {
assert_eq!(i["entities"][0]["transparency"], json!("ByBlock"));
}
#[test]
fn properties_set_thickness() {
// Issue #1722: `properties.set` used to ignore `thickness` while still reporting a change.
let mut s = Session::new();
let r = s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
let h = r["handle"].as_str().unwrap().to_string();
let hh = cadcraft_doc::Handle::parse_hex(&h).unwrap();
let th = |s: &Session| s.doc().unwrap().entity(hh).unwrap().common.thickness;
s.execute("properties.set", &json!({"handles": [h], "thickness": 8})).unwrap();
assert_eq!(th(&s), 8.0);
let p = s.execute("properties", &json!({"handles": [h]})).unwrap();
assert_eq!(p["objects"][0]["thickness"], json!(8.0));
assert!(s.execute("properties.set", &json!({"handles": [h], "thickness": "thick"})).is_err());
assert_eq!(th(&s), 8.0);
}
#[test]
fn zoom_extents_fits_drawing() {
let mut s = Session::new();
@@ -1293,6 +1309,64 @@ fn typed_ltscale_asks_for_the_scale() {
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1);
}
#[test]
fn format_thickness_asks_for_the_thickness() {
let mut s = Session::new();
s.start("thickness").unwrap();
assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text());
// After text that is not a number, the prompt asks again.
s.cmdline("thick").unwrap();
assert!(s.current_prompt().is_some(), "asked again");
assert_eq!(s.log.last().map(String::as_str), Some("Requires a number."));
// A value out of range says so, and the prompt asks again.
s.cmdline("2e12").unwrap();
assert!(s.current_prompt().is_some(), "asked again");
assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12."));
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0);
// A negative thickness is accepted.
s.cmdline("-1.5").unwrap();
assert!(s.current_prompt().is_none());
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
// Typed, Enter keeps the current thickness.
s.cmdline("thickness").unwrap();
assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5);
assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err());
assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err());
}
#[test]
fn new_objects_take_the_current_thickness() {
let mut s = Session::new();
s.execute("thickness", &json!({"thickness": 2.5})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
s.execute("point", &json!({"at": [1, 1]})).unwrap();
s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap();
s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap();
s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap();
let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect();
assert_eq!(thick.len(), 7, "{thick:?}");
assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}");
// RECTANG keeps its own thickness option, and text does not take THICKNESS.
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap();
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap();
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0);
let before = s.doc().unwrap().model.len();
s.cmdline("rectang 0,0 4,4").unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap();
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
// An ellipse has no thickness.
s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap();
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0);
}
fn setvar_prompt_open(s: &Session) -> bool {
s.running.as_ref().is_some_and(|r| r.id == "setvar")
}
+87 -24
View File
@@ -2,6 +2,10 @@
use std::f64::consts::PI;
/// Largest scaled value (below `i64::MAX`) that the integer feet/inch splitting handles; larger
/// distances fall back to decimal.
const I64_SAFE_MAX: f64 = 9.0e18;
/// Format a distance per LUNITS (1 scientific, 2 decimal, 3 engineering, 4 architectural,
/// 5 fractional) and LUPREC.
pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
@@ -25,7 +29,11 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
let neg = v < 0.0;
let a = v.abs();
let denom = 1i64 << prec.min(8);
let total = (a * denom as f64).round() as i64;
let scaled = (a * denom as f64).round();
if scaled >= I64_SAFE_MAX {
return format!("{:.*}", prec, v);
}
let total = scaled as i64;
let ft = total / (12 * denom);
let rem = total - ft * 12 * denom;
let inch = rem / denom;
@@ -42,7 +50,11 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
let neg = v < 0.0;
let a = v.abs();
let denom = 1i64 << prec.min(8);
let total = (a * denom as f64).round() as i64;
let scaled = (a * denom as f64).round();
if scaled >= I64_SAFE_MAX {
return format!("{:.*}", prec, v);
}
let total = scaled as i64;
let whole = total / denom;
let frac = total % denom;
let fs = if frac == 0 {
@@ -76,32 +88,35 @@ fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
}
/// Degrees (`[0, 360]`) as degrees/minutes/seconds, shown to the AUPREC precision (`12d34'57"`).
fn format_dms(deg: f64, prec: usize) -> String {
// Round at the displayed precision before splitting, carrying through a full turn.
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
let seconds_per_unit = match prec {
0 => 3600,
1 | 2 => 60,
_ => 1,
};
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
let total = units * seconds_per_unit % (360 * 3600 * scale);
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
match prec {
0 => format!("{d}d"),
1 | 2 => format!("{d}d{m}'"),
_ => {
let s = (total % (60 * scale)) as f64 / scale as f64;
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
}
}
}
/// Format an angle (radians) per AUNITS (0 decimal degrees, 1 deg/min/sec, 2 grads, 3 radians,
/// 4 surveyor's) and AUPREC.
pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
let prec = auprec.clamp(0, 8) as usize;
let deg = cadcraft_geom::norm_angle(a).to_degrees();
match aunits {
1 => {
// Round at the displayed precision before splitting, carrying through a full turn.
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
let seconds_per_unit = match prec {
0 => 3600,
1 | 2 => 60,
_ => 1,
};
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
let total = units * seconds_per_unit % (360 * 3600 * scale);
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
match prec {
0 => format!("{d}d"),
1 | 2 => format!("{d}d{m}'"),
_ => {
let s = (total % (60 * scale)) as f64 / scale as f64;
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
}
}
}
1 => format_dms(deg, prec),
2 => format!("{:.*}g", prec, wrap_full_turn(deg / 0.9, 400.0, prec)),
3 => format!("{:.*}r", prec, wrap_full_turn(cadcraft_geom::norm_angle(a), 2.0 * PI, prec)),
4 => {
@@ -115,7 +130,7 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
} else {
("S", "E", deg - 270.0)
};
format!("{ns} {:.*}d {ew}", prec, ang)
format!("{ns} {} {ew}", format_dms(ang, prec))
}
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
}
@@ -139,6 +154,9 @@ pub fn parse_distance(s: &str) -> Option<f64> {
Some(r) => (true, r.trim()),
None => (false, t),
};
if neg && t.starts_with(['-', '+']) {
return None;
}
let mut total = 0.0;
let mut rest = t;
let mut parsed = false;
@@ -175,7 +193,13 @@ fn parse_mixed(s: &str) -> Option<f64> {
};
match parts.as_slice() {
[a] => frac(a),
[a, b] => Some(a.parse::<f64>().ok()? + frac(b)?),
[a, b] => {
let (n, d) = b.split_once('/')?;
if n.trim().starts_with(['-', '+']) || d.trim().starts_with(['-', '+']) {
return None;
}
Some(a.parse::<f64>().ok()? + frac(b)?)
}
_ => None,
}
}
@@ -284,6 +308,9 @@ fn bearing(t: &str) -> Option<f64> {
None => (rest.strip_suffix('w')?, false),
};
let a = parse_dms(ang_s.trim())?;
if !(0.0..=90.0).contains(&a) {
return None;
}
let deg = match (north, east) {
(true, true) => 90.0 - a,
(true, false) => 90.0 + a,
@@ -329,6 +356,24 @@ pub const DEG: f64 = PI / 180.0;
mod tests {
use super::*;
#[test]
fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() {
assert_eq!(parse_distance("6 1/2"), Some(6.5));
assert_eq!(parse_distance("5 5"), None);
assert_eq!(parse_distance("1 -1/2"), None);
}
#[test]
fn huge_architectural_and_fractional_distances_do_not_saturate() {
for lunits in [4, 5] {
for v in [1e30, -1e30] {
assert_eq!(format_distance(v, lunits, 4), format!("{:.4}", v), "lunits {lunits}");
}
}
assert_eq!(format_distance(1e15, 5, 4), "1000000000000000");
assert_eq!(format_distance(12.5, 4, 4), "1'-0 1/2\"");
}
#[test]
fn directions_show_from_angbase_in_the_angdir_sense() {
let north_clockwise = AngleSettings { aunits: 0, angbase: 90f64.to_radians(), clockwise: true };
@@ -427,6 +472,14 @@ mod tests {
assert_eq!(parse_distance(""), None);
}
#[test]
fn parse_distance_rejects_doubled_minus() {
assert_eq!(parse_distance("--5"), None);
assert_eq!(parse_distance("- -3/4"), None);
assert_eq!(parse_distance("-5"), Some(-5.0));
assert_eq!(parse_distance("-3/4"), Some(-0.75));
}
#[test]
fn parse_distance_needs_a_number() {
assert_eq!(parse_distance("-"), None);
@@ -444,4 +497,14 @@ mod tests {
assert_eq!(format_angle(PI / 2.0, 0, 0), "90");
assert_eq!(format_angle(PI / 4.0, 4, 0), "N 45d E");
}
#[test]
fn surveyor_display_is_degrees_minutes_seconds() {
// 73.6975 degrees south of east: 73d41'51" (0.6975 deg = 41.85' = 41'51")
let angle = (270.0 + 73.6975_f64).to_radians();
assert_eq!(format_angle(angle, 4, 4), "S 73d41'51\" E");
assert_eq!(format_angle(angle, 4, 2), "S 73d42' E");
assert_eq!(format_angle(angle, 4, 0), "S 74d E");
assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E");
}
}
+36
View File
@@ -211,6 +211,42 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Autosave files are unsaved drawings: on Unix an autosave folder that doesn't exist yet is made
/// owner-only, and one that already exists (SAVEFILEPATH can be any folder) keeps its mode.
#[cfg(unix)]
#[test]
fn a_new_autosave_folder_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
hooks();
let base = temp_dir("modes");
let picked = base.join("picked");
std::fs::create_dir_all(&picked).unwrap();
std::fs::set_permissions(&picked, std::fs::Permissions::from_mode(0o755)).unwrap();
for (dir, expected) in [(base.join("new").join("autosave"), 0o700), (picked, 0o755)] {
let mut s = Session::new();
draw(&mut s);
// Not instance_id(): a test started in the same millisecond would get the same id, and
// shutdown() would then remove its lock from the process-wide SAVEFILEPATH.
let mut a = Autosaver::new("41-c");
a.plan(&s, 0.0, 1, &dir.to_string_lossy());
let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy());
let path = writes(&jobs).pop().unwrap();
a.perform(jobs);
for _ in 0..500 {
if std::path::Path::new(&path).exists() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(std::path::Path::new(&path).exists());
assert_eq!(mode(&dir), expected, "{}", dir.display());
a.shutdown();
}
assert_eq!(mode(&base.join("new")), 0o700);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_failed_autosave_is_tried_again_without_another_change() {
hooks();
+112
View File
@@ -170,6 +170,118 @@ fn pasteblock_makes_a_block_of_the_clipboard() {
assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some());
}
#[test]
fn cutbase_cuts_with_the_given_base_point() {
let mut s = Session::empty();
let mut da = drawing_a();
if let Some(l) = da.layer_mut("WALLS") {
l.locked = true;
}
let a = s.open_drawing(da, "A", None);
let b = s.open_drawing(drawing_b(), "B", None);
s.active = a;
let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap();
// The wall is on a locked layer, so only the door is cut.
assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." }));
assert_eq!(s.clipboard.len(), 1);
assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0));
let left = model(&s);
assert_eq!(left.len(), 1);
assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays");
// PASTECLIP places the base point at the insertion point.
s.active = b;
s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap();
let pasted = model(&s);
assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0));
// Undo in A brings the door back.
s.active = a;
s.execute("u", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
}
#[test]
fn cutting_only_locked_objects_keeps_the_clipboard() {
let mut s = Session::empty();
let mut da = drawing_a();
for l in &mut da.layers {
l.locked = true;
}
s.open_drawing(da, "A", None);
let hs = handles(&s);
s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap();
let held = s.clipboard.clone();
let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap();
assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." }));
assert_eq!(model(&s).len(), 2, "nothing was erased");
assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held");
assert_eq!(s.clipboard[0].handle, held[0].handle);
// CUTBASE at the command line says so too.
s.start("cutbase").unwrap();
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
let wall = s.doc().unwrap().model.iter().next().unwrap().handle;
s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap();
s.input(cadcraft_engine::Input::Enter).unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log);
assert_eq!(s.clipboard.len(), 1);
assert_eq!(model(&s).len(), 2);
}
#[test]
fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() {
let mut s = Session::empty();
s.open_drawing(drawing_a(), "A", None);
let hs = handles(&s);
for cmd in ["cutbase", "copybase"] {
for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}");
}
}
assert_eq!(model(&s).len(), 2, "nothing was cut");
assert!(s.clipboard.is_empty(), "nothing was copied");
// A handle listed twice is copied and cut once.
let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] });
assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 }));
assert_eq!(s.clipboard.len(), 1);
assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 }));
assert_eq!(s.clipboard.len(), 1);
assert_eq!(model(&s).len(), 1);
}
#[test]
fn cutbase_asks_for_the_base_point_then_the_objects() {
let mut s = Session::empty();
s.open_drawing(drawing_a(), "A", None);
let hs: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
let undo_before = s.state().unwrap().undo.len();
s.start("cutbase").unwrap();
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap();
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap();
s.input(cadcraft_engine::Input::Enter).unwrap();
assert!(s.running.is_none());
assert_eq!(s.clipboard.len(), 1);
assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0));
assert_eq!(model(&s).iter().map(|e| e.handle).collect::<Vec<_>>(), [hs[0]]);
assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step");
// With a pickfirst selection, the base point is the only prompt.
s.set_selection(vec![hs[0]]);
s.start("cutbase").unwrap();
assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text());
s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap();
assert!(s.running.is_none());
assert!(s.doc().unwrap().model.is_empty());
assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0));
assert!(s.selection().is_empty());
}
/// A stand-in for the DXF writer and reader: "writes" the drawing to memory.
static WRITTEN: Mutex<Option<Drawing>> = Mutex::new(None);
+69 -4
View File
@@ -104,16 +104,40 @@ fn navigates_areas_and_closes() {
assert!(s.compare.is_none());
}
#[test]
fn compares_a_file_read_through_the_io_hooks() {
// Test file format: the bytes are "x" — a drawing holding a line from 0,0 to x,0.
/// The file-reading hook both tests here share (it is set once per process). Test file format:
/// "xref" is a drawing with a reference to xref Y at `y.dwg`; a number x is a drawing holding a
/// line from 0,0 to x,0.
fn hooks() {
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?;
let mut d = Drawing::new_imperial();
if bytes == b"xref" {
let mut y = Block::new("Y");
y.xref_path = Some("y.dwg".into());
d.blocks.insert("Y".into(), Arc::new(y));
let insert = Insert {
block: "Y".into(),
insert: Vec3::ZERO,
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,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?;
return Ok(d);
}
let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?;
d.add(&Space::Model, Common::default(), line(0.0, 0.0, x, 0.0)).map_err(|e| e.to_string())?;
Ok(d)
}
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
}
#[test]
fn compares_a_file_read_through_the_io_hooks() {
hooks();
let dir = std::env::temp_dir().join(format!("cadcraft-compare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("other.dxf");
@@ -194,3 +218,44 @@ fn derived_dimension_fields_are_not_differences() {
let r = s.execute("compare", &json!({"document": 0})).unwrap();
assert_eq!(counts(&r), (0, 0, 0, 1), "{r}");
}
/// COMPAREEXPORT (#2049): an xref path relative to the compared file keeps that file's folder.
#[test]
fn relative_xref_paths_of_a_compared_file_take_that_files_folder() {
hooks();
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("other.dxf");
std::fs::write(&path, "xref").unwrap();
// Named by its absolute path and by a path relative to the working directory.
let mut names = vec![path.to_string_lossy().to_string()];
names.extend(relative_to_cwd(&path));
for name in names {
let mut s = Session::new();
s.execute("compare", &json!({"path": name})).unwrap();
// The compared file's xref is only there; the copy keeps the file's folder.
s.execute("compareexport", &json!({})).unwrap();
let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap();
let got = std::path::Path::new(&got);
assert!(got.is_absolute(), "{got:?}, compared as {name}");
assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg")));
assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}");
}
std::fs::remove_dir_all(&dir).ok();
}
/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when
/// the two share a root.
fn relative_to_cwd(p: &std::path::Path) -> Option<String> {
use std::path::Component;
let cwd = std::env::current_dir().ok()?;
let (a, b): (Vec<Component>, Vec<Component>) = (cwd.components().collect(), p.components().collect());
let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
if shared == 0 {
return None;
}
let mut rel = std::path::PathBuf::new();
a[shared..].iter().for_each(|_| rel.push(".."));
b[shared..].iter().for_each(|c| rel.push(c));
Some(rel.to_string_lossy().to_string())
}
+70
View File
@@ -0,0 +1,70 @@
//! #1645: a diameter dimension's text in its `*D` block, and in the MTEXT EXPLODE leaves, holds
//! the diameter sign as `%%c`, as CADCraft's own TEXT and MTEXT keep it. The decoded ⌀ (U+2300)
//! is in no SHX font, so other programs plotted the dimension in an SHX style ("romans.shx")
//! without its sign. CADCraft still draws and reads the sign.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use serde_json::{Value, json};
fn h(v: &Value) -> Handle {
v["handle"].as_str().and_then(Handle::parse_hex).unwrap()
}
/// The issue's drawing: an SHX text style, a dimension style using it, a diameter dimension
/// of a circle of radius 30.
fn session() -> (Session, Handle) {
let mut s = Session::new();
s.execute("units", &json!({"measurement": "metric"})).ok();
s.execute("style", &json!({"name": "R", "font": "romans.shx", "current": true})).unwrap();
s.execute("dimstyle", &json!({"name": "D", "current": true, "DIMTXSTY": "R", "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0})).unwrap();
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 30})).unwrap());
let dim = h(&s.execute("dimdiameter", &json!({"handle": c.hex(), "at": [40, 40]})).unwrap());
(s, dim)
}
/// The MTEXT contents (group 1) inside each `*D…` block of a DXF.
fn dblock_texts(dxf: &str) -> Vec<String> {
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut out = Vec::new();
let (mut in_d, mut in_mtext) = (false, false);
for pair in lines.chunks(2) {
let [code, value] = pair else { continue };
match (*code, *value) {
("0", "BLOCK") => in_d = false,
("2", name) if !in_mtext && name.starts_with("*D") => in_d = true,
("0", "MTEXT") => in_mtext = in_d,
("0", _) => in_mtext = false,
("1", text) if in_mtext => out.push(text.to_string()),
_ => {}
}
}
out
}
#[test]
fn the_dimension_block_writes_the_sign_as_a_code() {
let (s, dim) = session();
let d = s.doc().unwrap();
let EntityKind::Dimension(dm) = &d.entity(dim).unwrap().kind else { panic!("not a dimension") };
assert!(matches!(dm.kind, cadcraft_engine::doc::DimKind::Diameter));
let texts = dblock_texts(&cadcraft_io::write_dxf(d));
assert_eq!(texts, vec!["%%c60".to_string()], "the *D block's MTEXT");
// Read back, CADCraft still shows the sign.
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(d).as_bytes()).unwrap();
let dm = read.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.clone()) } else { None }).unwrap();
let st = read.dim_styles.iter().find(|x| x.name.eq_ignore_ascii_case(&dm.style)).cloned().unwrap_or_default();
assert_eq!(cadcraft_render::dimension_geometry(&dm, &st, 1.0).value, "⌀60");
}
#[test]
fn exploding_keeps_the_sign_as_a_code() {
let (mut s, dim) = session();
s.execute("explode", &json!({"handles": [dim.hex()]})).unwrap();
let d = s.doc().unwrap();
let texts: Vec<String> =
d.model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect();
assert_eq!(texts, vec!["%%c60".to_string()]);
let saved = cadcraft_io::write_dxf(d);
assert!(!saved.contains('⌀'), "no U+2300 is saved");
}
+140
View File
@@ -0,0 +1,140 @@
//! #1651: the issue's repro through the commands. Every dimension CADCraft reports as
//! associated (the diameter, radius and angular ones, as well as the linear control) is saved
//! with a DIMASSOC object whose references name its geometry.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use serde_json::{Value, json};
/// The entity handle a command result reports, from its `handle` field.
fn handle(r: &Value) -> Handle {
Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()
}
/// The first line of a LINE result (`handles`).
fn first_line(r: &Value) -> Handle {
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// The DXF tag pairs, `(code, value)`.
fn pairs(text: &str) -> Vec<(i32, String)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for chunk in lines.chunks(2) {
let [code, value] = chunk else { break };
let Ok(c) = code.trim().parse::<i32>() else { continue };
out.push((c, value.trim().to_string()));
}
out
}
/// The DIMASSOC object of a dimension, if the file has one.
fn dimassoc(text: &str, dim: Handle) -> Option<Vec<(i32, String)>> {
let hex = dim.hex();
let mut recs = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in pairs(text) {
if c == 0 {
if let Some(r) = cur.take() {
recs.push(r);
}
cur = Some(vec![(c, v)]);
} else if let Some(r) = cur.as_mut() {
r.push((c, v));
}
}
if let Some(r) = cur {
recs.push(r);
}
recs.into_iter()
.filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC"))
.find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex)))
}
/// The `AcDbOsnapPointRef` blocks of a DIMASSOC record, in file order: one per reference the
/// dimension makes to its geometry.
fn point_refs(rec: &[(i32, String)]) -> Vec<Vec<(i32, String)>> {
let mut out = Vec::new();
let mut cur: Option<Vec<(i32, String)>> = None;
for (c, v) in rec {
if *c == 1 && v == "AcDbOsnapPointRef" {
if let Some(r) = cur.take() {
out.push(r);
}
cur = Some(Vec::new());
} else if let Some(r) = cur.as_mut() {
r.push((*c, v.clone()));
}
}
if let Some(r) = cur {
out.push(r);
}
out
}
/// The first integer value of a group code in a record (72 the osnap type, 90 the flags).
fn int(rec: &[(i32, String)], code: i32) -> Option<i64> {
rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok())
}
/// The first string value of a group code in a record (331 the referenced entity, 332 the other
/// entity of an intersection).
fn string(rec: &[(i32, String)], code: i32) -> Option<&str> {
rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
}
/// #1651's repro through the commands: after DIMREASSOCIATE, every dimension the session calls
/// associated (diameter, radius, angular and a linear control) is written with a DIMASSOC whose
/// references name real geometry. Guards the saving side, where radial and diametric dimensions
/// used to fall through the kind-to-slot match and be written without any DIMASSOC object.
#[test]
fn the_issue_repro_saves_every_associated_dimension_with_dimassoc() {
let mut s = Session::new();
s.execute("dimstyle", &json!({ "name": "D", "current": true, "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0 })).unwrap();
let circle = handle(&s.execute("circle", &json!({ "center": [0, 0], "radius": 30 })).unwrap());
let diameter = handle(&s.execute("dimdiameter", &json!({ "handle": circle.hex(), "at": [40, 40] })).unwrap());
let radius = handle(&s.execute("dimradius", &json!({ "handle": circle.hex(), "at": [-40, -40] })).unwrap());
let l1 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [200, 0]] })).unwrap());
let l2 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [180, 60]] })).unwrap());
let angular = handle(&s.execute("dimangular", &json!({ "lines": [l1.hex(), l2.hex()], "at": [160, 20] })).unwrap());
let l3 = first_line(&s.execute("line", &json!({ "points": [[0, -80], [100, -80]] })).unwrap());
let linear = handle(&s.execute("dimlinear", &json!({ "object": l3.hex(), "at": [50, -100] })).unwrap());
let dims = [diameter, radius, angular, linear];
{
let d = s.doc().unwrap();
for h in dims {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Dimension(dm)) => assert!(!dm.assoc.is_empty(), "{} is associative in CADCraft", h.hex()),
_ => panic!("{} is not a dimension", h.hex()),
}
}
}
// The issue runs DIMREASSOCIATE before saving; it does not lose any of them.
let hs: Vec<String> = dims.iter().map(|h| h.hex()).collect();
let r = s.execute("dimreassociate", &json!({ "handles": hs })).unwrap();
assert_eq!(r["associated"], json!(4), "{r}");
let text = cadcraft_io::write_dxf(s.doc().unwrap());
for (h, what) in [(diameter, "diameter"), (radius, "radius"), (angular, "angular"), (linear, "linear")] {
let rec = dimassoc(&text, h).unwrap_or_else(|| panic!("{what} dimension {} has a DIMASSOC", h.hex()));
let refs = point_refs(&rec);
assert!(!refs.is_empty(), "{what}: the DIMASSOC has point references");
assert!(refs.iter().all(|r| string(r, 331).is_some()), "{what}: every reference names geometry");
}
// The radial and diametric dimensions name the circle by its centre.
for (h, what) in [(diameter, "diameter"), (radius, "radius")] {
let refs = point_refs(&dimassoc(&text, h).unwrap());
assert_eq!(refs.len(), 1, "{what}: one reference, as AutoCAD writes radial dimensions");
assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap");
assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&circle.hex())), "{what}: to the circle");
}
// The angular dimension names both lines, and the linear one its line.
let refs = point_refs(&dimassoc(&text, angular).unwrap());
assert!(refs.len() >= 2, "angular: at least both leg points: {refs:?}");
let named = |r: &[(i32, String)], h: Handle| string(r, 331).is_some_and(|x| x.eq_ignore_ascii_case(&h.hex()));
assert!(refs.iter().any(|r| named(r, l1)) && refs.iter().any(|r| named(r, l2)), "angular: both lines");
let refs = point_refs(&dimassoc(&text, linear).unwrap());
assert_eq!(refs.len(), 2, "linear: both origins");
assert!(refs.iter().all(|r| named(r, l3)), "linear: its line");
}
+32
View File
@@ -59,6 +59,38 @@ fn attdisp_sets_attmode_and_the_display_follows_it() {
assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err());
}
#[test]
fn view_attribute_display_menu_sets_attmode() {
let mut s = Session::new();
for (id, item, mode) in [("attdisp.on", "On", 2), ("attdisp.off", "Off", 0), ("attdisp.normal", "Normal", 1)] {
let spec = cadcraft_engine::find_command(id).unwrap_or_else(|| panic!("no command {id}"));
assert_eq!(spec.menu, ["View", "Attribute Display", item]);
s.start(id).unwrap();
assert!(s.running.is_none(), "{id}: {}", s.prompt_text());
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), mode, "{id}");
}
assert_eq!(s.execute("attdisp.off", &json!({})).unwrap(), json!({"attmode": 0}));
s.undo().unwrap();
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 1);
}
#[test]
fn attdisp_runs_transparently_inside_another_command() {
let mut s = Session::new();
s.cmdline("LINE 0,0").unwrap();
s.cmdline("'ATTDISP").unwrap();
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("attdisp"));
assert!(s.prompt_text().starts_with(">>"), "{}", s.prompt_text());
s.cmdline("off").unwrap();
assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0);
// LINE prompts again for its next point.
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("line"), "{}", s.prompt_text());
s.cmdline("10,0").unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(s.doc().unwrap().model.iter().count(), 1);
}
#[test]
fn attsync_brings_references_in_line_with_the_definitions() {
let mut s = Session::new();
+6
View File
@@ -40,6 +40,12 @@ fn a_block_cannot_be_redefined_to_reference_itself() {
let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] }));
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
assert_eq!(contents(&s, "A"), ["line"]);
// Through a multileader whose content is the block being redefined.
let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone();
let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] }));
assert!(r.is_err_and(|e| e.to_string().contains("references itself")));
assert_eq!(contents(&s, "A"), ["line"]);
}
#[test]
+223
View File
@@ -0,0 +1,223 @@
//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and
//! other blocks, becomes a reference of another at the same insertion point, scale and rotation.
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space};
use cadcraft_engine::{Input, Session};
use serde_json::{Value, json};
/// Make block `name` from what is in model space now (the objects are removed).
fn make_block(s: &mut Session, name: &str) {
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
}
/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a
/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated
/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A.
fn drawing() -> (Session, Handle) {
let mut s = Session::new();
s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap();
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
make_block(&mut s, "A");
s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap();
s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
make_block(&mut s, "B");
s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap();
make_block(&mut s, "N");
s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap();
s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap();
let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap();
let ins = Insert {
block: "A".into(),
insert: Vec3::new(0.0, 0.0, 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,
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap();
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
}
/// The block names of the references in `ents`.
fn refs<'a>(ents: impl Iterator<Item = &'a std::sync::Arc<Entity>>) -> Vec<String> {
ents.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some(i.block.clone()),
_ => None,
})
.collect()
}
fn insert(s: &Session, h: Handle) -> (Insert, String) {
let e = s.doc().unwrap().entity(h).unwrap();
let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") };
(i.clone(), e.common.layer.clone())
}
#[test]
fn blockreplace_replaces_every_reference_and_keeps_its_placement() {
let (mut s, h) = drawing();
let before = insert(&s, h).0;
let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap();
assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false}));
let (after, layer) = insert(&s, h);
assert_eq!(after.block, "B");
assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R"));
// The replacement's attributes, with the value of the tag both blocks define.
let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect();
assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]);
let d = s.doc().unwrap();
assert_eq!(refs(d.model.iter()), ["N", "B"]);
assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]);
assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]);
assert!(d.block("A").is_some(), "the definition stays without `purge`");
s.execute("undo", &json!({})).unwrap();
assert_eq!(insert(&s, h).0, before);
}
#[test]
fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() {
let (mut s, h) = drawing();
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone();
s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap();
let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len();
assert_eq!(scales(&s), 0);
s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap();
assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale");
s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap();
assert_eq!(scales(&s), 0);
}
#[test]
fn blockreplace_purges_the_replaced_definition_on_request() {
let (mut s, _) = drawing();
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
assert_eq!(r["purged"], json!(true));
assert!(s.doc().unwrap().block("A").is_none());
}
#[test]
fn blockreplace_keeps_a_definition_an_unused_block_still_names() {
let (mut s, _) = drawing();
// Block U, never inserted, holds a multileader whose content is block A.
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone();
s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap();
assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false)));
assert!(s.doc().unwrap().block("A").is_some());
}
#[test]
fn blockreplace_refuses_what_it_cannot_replace() {
let (mut s, _) = drawing();
let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string();
assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block"));
assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found"));
assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found"));
// N holds a reference of A, so N replacing A would reference itself.
assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references"));
// M holds a multileader whose content is N, so M reaches A too.
let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone();
s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap();
assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references"));
assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`"));
assert!(err(&mut s, json!({"with": "B"})).contains("`block`"));
// A block an xref brings in is not saved with the drawing.
s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A")));
assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref"));
assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref"));
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
// The Block Editor shows the block, not the drawing's references.
s.execute("bedit", &json!({"name": "B"})).unwrap();
assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor"));
}
#[test]
fn blockreplace_asks_for_both_blocks_and_whether_to_purge() {
let (mut s, h) = drawing();
s.cmdline("-blockreplace").unwrap();
assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text());
s.cmdline("?").unwrap();
assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log);
s.cmdline("a").unwrap();
assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text());
// A cannot replace itself: the prompt asks again.
s.cmdline("A").unwrap();
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
s.input(Input::Cancel).unwrap();
assert!(s.running.is_none());
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
// The block to replace picked from one of its references; Enter purges it.
s.cmdline("blockreplace").unwrap();
s.cmdline("Select").unwrap();
s.input(Input::Pick(vec![h])).unwrap();
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
s.cmdline("B").unwrap();
assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log);
let d = s.doc().unwrap();
assert!(d.block("A").is_none());
assert_eq!(refs(d.model.iter()), ["N", "B"]);
assert_eq!(insert(&s, h).0.block, "B");
}
#[test]
fn blockreplace_no_keeps_the_replaced_definition() {
let (mut s, _) = drawing();
s.cmdline("blockreplace").unwrap();
s.cmdline("A").unwrap();
s.cmdline("B").unwrap();
s.cmdline("No").unwrap();
assert!(s.running.is_none());
let d = s.doc().unwrap();
assert!(d.block("A").is_some());
assert_eq!(refs(d.model.iter()), ["N", "B"]);
}
#[test]
fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() {
let (mut s, _) = drawing();
s.cmdline("blockreplace").unwrap();
s.cmdline("A").unwrap();
// N holds a reference of A: refused at once, and the prompt asks for another name.
s.cmdline("N").unwrap();
assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log);
assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text());
s.cmdline("B").unwrap();
assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text());
s.input(Input::Cancel).unwrap();
// A dynamic block is refused as the block to replace.
let mut d = s.doc().unwrap().clone();
let mut f = Foreign::default();
f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect();
f.objects.push(ForeignObject {
handle: Handle(0xF00),
kind: "DICTIONARY".into(),
tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]),
});
f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) });
d.foreign = std::sync::Arc::new(f);
s.open_drawing(d, "dynamic", None);
let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string();
assert!(e.contains("dynamic block"), "{e}");
s.cmdline("blockreplace").unwrap();
s.cmdline("A").unwrap();
assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log);
assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text());
s.input(Input::Cancel).unwrap();
assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]);
}
+882
View File
@@ -0,0 +1,882 @@
//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and
//! the other drawing's differing objects in another, in the comparison colors. A compared file's
//! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::color::{Color, Rgb};
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space};
use serde_json::json;
fn insert(block: &str) -> Insert {
Insert {
block: block.into(),
insert: Vec3::ZERO,
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,
}
}
fn line(x: f64) -> EntityKind {
EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) })
}
fn handle_of(s: &Session, ty: &str) -> String {
s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex()
}
/// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text
/// is gone and an arc was added. The comparison runs in "B" against "Drawing1".
fn compared() -> Session {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap();
let d: Drawing = s.doc().unwrap().clone();
s.open_drawing(d, "B.dxf", None);
let circle = handle_of(&s, "Circle");
let text = handle_of(&s, "Text");
s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap();
s.execute("erase", &json!({"handles": [text]})).unwrap();
s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
s.execute("compare", &json!({"document": "Drawing1"})).unwrap();
s
}
/// The objects of a group of the active drawing, in group order.
fn members(s: &Session, group: &str) -> Vec<Arc<Entity>> {
let d = s.doc().unwrap();
let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}"));
g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect()
}
/// The (type, color) of each object in a group of the active drawing, sorted.
fn contents(s: &Session, group: &str) -> Vec<(String, Color)> {
let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
/// The x of each line's end in a block of the active drawing.
fn ends(s: &Session, block: &str) -> Vec<f64> {
let d = s.doc().unwrap();
d.block(block)
.unwrap()
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some(l.b.x),
_ => None,
})
.collect()
}
const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b));
const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c));
const GRAY: Color = Color::Index(8);
#[test]
fn exports_both_drawings_as_colored_groups_in_a_new_drawing() {
let mut s = compared();
let before = s.docs.len();
let before_epoch = s.doc_epoch;
let source = s.doc().unwrap().clone();
let r = s.execute("compareexport", &json!({})).unwrap();
assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures");
assert_eq!(s.docs.len(), before + 1, "{r}");
assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}");
assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg");
assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled");
assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it");
assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}");
assert_eq!(r["truncated"], false);
let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap());
assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED"));
// This drawing: the moved circle and the new arc in green, the unchanged line in gray.
assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]);
// The other drawing: the circle where it was and the erased text, in red, in its draw order.
assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]);
let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect();
assert_eq!(order, ["Circle", "Text"]);
let circle_there = members(&s, other).iter().find_map(|e| match &e.kind {
EntityKind::Circle(c) => Some(c.center),
_ => None,
});
assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0)));
// Model space holds the other drawing's objects first, then this drawing's.
let d = s.doc().unwrap();
let order: Vec<Handle> = d.model.iter().map(|e| e.handle).collect();
let groups: Vec<Handle> = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect();
assert_eq!(order, groups);
// Every handle is new and unique.
let mut seen = std::collections::HashSet::new();
for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) {
assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex());
}
// The drawing COMPARE ran in is unchanged, and the comparison stays on it.
let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap();
assert!(*s.docs[back].doc == source);
assert!(s.compare.is_some());
}
#[test]
fn takes_the_other_drawings_layers_and_blocks_it_needs() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
// The other drawing holds a reference to a block this one lacks, on a layer this one lacks.
let mut other = s.doc().unwrap().clone();
other.handseed = 0x9000;
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT"))));
let mut bolt = Block::new("BOLT");
bolt.entities.push(Entity::new(Handle(0x8001), line(1.0)));
other.blocks.insert("PART".into(), Arc::new(part));
other.blocks.insert("BOLT".into(), Arc::new(bolt));
other.layers.push(Layer::new("Parts"));
other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap();
// A group of this drawing's name does not carry over, so the snapshot takes the first name.
s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
assert_eq!(r["current"], "COMPARE_CURRENT", "{r}");
let d = s.doc().unwrap();
assert_eq!(d.groups.len(), 2);
assert!(d.layer("Parts").is_some());
assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied");
assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies");
assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000));
let refs: Vec<String> = members(&s, "COMPARE_COMPARED")
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)),
_ => None,
})
.collect();
assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]);
}
#[test]
fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() {
let mut s = Session::new();
let mut mine = Block::new("*U1");
mine.entities.push(Entity::new(Handle(0x700), line(1.0)));
s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap();
// The other drawing uses the same anonymous name for other geometry.
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
let mut theirs = Block::new("*U1");
theirs.entities.push(Entity::new(Handle(0x8000), line(5.0)));
other.blocks.insert("*U1".into(), Arc::new(theirs));
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept");
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some(i.block.clone()),
_ => None,
})
.collect();
// The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy.
assert_eq!(ends(&s, "*U2"), vec![5.0]);
assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}");
assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]);
assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]);
}
#[test]
fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() {
// PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader
// whose content is SYMBOL, which holds one line.
let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| {
let mut part = Block::new("PART");
part.base = Vec3::new(part_base, 0.0, 0.0);
part.entities.push(Entity::new(Handle(first), line(part_end)));
part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB"))));
let leader = MLeader {
leaders: Vec::new(),
landing: Vec3::ZERO,
dogleg: 0.0,
text: None,
style: "Standard".into(),
arrow_size: 1.0,
spline: false,
block: Some(insert("SYMBOL")),
};
part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader)));
let mut sub = Block::new("SUB");
sub.base = Vec3::new(sub_base, 0.0, 0.0);
sub.entities.push(Entity::new(Handle(first + 3), line(2.0)));
let mut symbol = Block::new("SYMBOL");
symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end)));
[part, sub, symbol]
};
let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| {
let mut s = Session::new();
for b in blocks(1.0, 0.0, 0.0, 3.0, 0x700) {
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
}
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
for b in blocks(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) {
other.blocks.insert(b.name.clone(), Arc::new(b));
}
// A second reference, so the other drawing has one that differs even when PART does not.
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
let referenced: Vec<String> = members(&s, r["compared"].as_str().unwrap())
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some(i.block.clone()),
_ => None,
})
.collect();
(s, referenced)
};
// The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red.
let (s, referenced) = run(5.0, 0.0, 0.0, 3.0);
assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept");
assert_eq!(ends(&s, "PART_2"), vec![5.0]);
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]);
let d = s.doc().unwrap();
assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings");
// The blocks a copy of PART refers to.
let refers = |s: &Session| -> Vec<String> {
s.doc()
.unwrap()
.block("PART_2")
.unwrap()
.entities
.iter()
.flat_map(|e| match &e.kind {
EntityKind::Insert(i) => vec![i.block.clone()],
EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(),
_ => Vec::new(),
})
.collect()
};
// Another base point on PART, another base point on the SUB inside it, or another SYMBOL
// in its multileader: PART is copied too, with the blocks that differ.
let (s, referenced) = run(1.0, 3.0, 0.0, 3.0);
assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0));
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]);
assert_eq!(refers(&s), ["SUB", "SYMBOL"]);
let (s, _) = run(1.0, 0.0, 4.0, 3.0);
assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0));
assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]);
let (s, _) = run(1.0, 0.0, 0.0, 6.0);
assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]);
assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]);
// The same definitions in both drawings: the red reference uses this drawing's PART.
let (s, referenced) = run(1.0, 0.0, 0.0, 3.0);
assert!(s.doc().unwrap().block("PART_2").is_none());
assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]);
assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]);
}
#[test]
fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() {
use cadcraft_engine::doc::{DimKind, Dimension};
// PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`,
// holds a line of another length in the other drawing.
let dim = Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(15.0, 5.0, 0.0),
text_mid: Vec3::new(7.0, 5.0, 0.0),
p13: Vec3::ZERO,
p14: Vec3::new(30.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 30.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
let blocks = |dim_end: f64, first: u64| {
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone())));
let mut drawn = Block::new("*D1");
drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end)));
[part, drawn]
};
let mut s = Session::new();
for b in blocks(1.0, 0x700) {
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
}
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
for b in blocks(5.0, 0x8000) {
other.blocks.insert(b.name.clone(), Arc::new(b));
}
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept");
let d = s.doc().unwrap();
let drawn: Vec<Option<String>> = d
.block("PART_2")
.map(|b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.block.clone()),
_ => None,
})
.collect()
})
.unwrap_or_default();
assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block");
assert_eq!(ends(&s, "*D2"), vec![5.0]);
// The colored copies of PART refer to colored copies of the dimension block, which are
// anonymous `*D` blocks of their own, not the originals.
let d = s.doc().unwrap();
let copies: Vec<String> = d
.blocks
.values()
.filter(|b| b.name.starts_with("COMPARE_"))
.flat_map(|b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()),
_ => None,
})
.collect::<Vec<_>>()
})
.collect();
assert!(!copies.is_empty(), "PART has colored copies");
for n in &copies {
assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}");
assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block");
}
}
#[test]
fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() {
use cadcraft_engine::doc::{DimKind, Dimension};
// PART holds the same dimension in both drawings; the block it draws holds the same line but
// is named `*D1` here and `*D3` in the other drawing.
let blocks = |drawn_name: &str, first: u64| {
let dim = Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(15.0, 5.0, 0.0),
text_mid: Vec3::new(7.0, 5.0, 0.0),
p13: Vec3::ZERO,
p14: Vec3::new(30.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 30.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some(drawn_name.into()),
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim)));
let mut drawn = Block::new(drawn_name);
drawn.entities.push(Entity::new(Handle(first + 1), line(1.0)));
[part, drawn]
};
let mut s = Session::new();
for b in blocks("*D1", 0x700) {
s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b));
}
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
for b in blocks("*D3", 0x8000) {
other.blocks.insert(b.name.clone(), Arc::new(b));
}
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings");
}
#[test]
fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() {
let run = |theirs: Common| {
let part = |common: Common, first: u64| {
let mut part = Block::new("PART");
let mut e = Entity::new(Handle(first), line(1.0));
e.common = common;
part.entities.push(e);
part
};
let mut s = Session::new();
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700)));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000)));
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
s.doc().unwrap().block("PART_2").is_some()
};
assert!(run(Common { visible: false, ..Common::default() }), "a hidden line");
assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness");
assert!(!run(Common::default()), "the same line");
}
#[test]
fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() {
// PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is
// a file in another folder, so its `a.png` is another file.
let part = |first: u64| {
let at = serde_json::to_value(Vec3::ZERO).unwrap();
let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap();
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(first), EntityKind::Image(image)));
part
};
let dir = std::env::temp_dir().join("compareexport-image-folder");
let mut s = Session::new();
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700)));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = Drawing::new_imperial();
other.handseed = 0x9000;
other.blocks.insert("PART".into(), Arc::new(part(0x8000)));
other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap();
s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string()));
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
let d = s.doc().unwrap();
let paths: Vec<String> = d
.block("PART_2")
.map(|b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i.path.clone()),
_ => None,
})
.collect()
})
.unwrap_or_default();
assert_eq!(paths.len(), 1, "the other drawing's PART is copied");
assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path()));
}
#[test]
fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() {
use cadcraft_engine::doc::{DimKind, Dimension};
// The dimension's block holds a line its parameters would not draw: a block read from a file.
let dim = Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(15.0, 5.0, 0.0),
text_mid: Vec3::new(7.0, 5.0, 0.0),
p13: Vec3::ZERO,
p14: Vec3::new(30.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 30.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
let mut s = Session::new();
let mut drawn = Block::new("*D1");
drawn.entities.push(Entity::new(Handle(0x701), line(123.0)));
s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap();
s.open_drawing(Drawing::new_imperial(), "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let block = read
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::Dimension(dm) => dm.block.clone(),
_ => None,
})
.unwrap();
let ends: Vec<f64> = read
.block(&block)
.unwrap()
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some(l.b.x),
_ => None,
})
.collect();
assert_eq!(ends, [123.0], "the saved dimension block is the copy's");
}
#[test]
fn block_contents_take_the_comparison_color() {
let mut s = Session::new();
let mut part = Block::new("PART");
part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) });
part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART"))));
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap();
let mut other = s.doc().unwrap().clone();
other.model = Default::default();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
let objs = members(&s, r["current"].as_str().unwrap());
let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") };
assert_eq!(i.block, "COMPARE_CURRENT_PART");
let d = s.doc().unwrap();
let copy = d.block("COMPARE_CURRENT_PART").unwrap();
assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too");
// The block that refers to itself refers to its own copy, which ends the copying.
assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART")));
// This drawing's definition is left as it was.
assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1));
}
#[test]
fn colors_carried_apart_from_the_object_color_are_replaced() {
let mut s = Session::new();
s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap();
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap();
s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap();
s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap();
s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap();
let d = s.doc_mut().unwrap();
d.dim_styles[0].dim_line_color = Color::Index(1);
d.dim_styles[0].text_color = Color::Index(5);
let mut data = d.table_styles[0].cell_style(CellStyle::DATA);
(data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3));
d.table_styles[0].cell_styles.push(data);
let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle;
d.modify_entity(mline, |e| {
if let EntityKind::MLine(m) = &mut e.kind {
m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3));
m.def.fill = Some(Color::Index(4));
}
})
.unwrap();
let other = Drawing::new_imperial();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
let mut kinds = Vec::new();
let d = s.doc().unwrap();
for e in members(&s, r["current"].as_str().unwrap()) {
match &e.kind {
EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"),
EntityKind::Dimension(dm) => {
let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides);
assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock));
}
EntityKind::Table(t) => {
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x"));
let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap();
let data = st.cell_style(CellStyle::DATA);
assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock));
}
EntityKind::Tolerance(t) => {
assert_eq!(t.style, "COMPARE_CURRENT_Standard");
let st = d.dim_style(&t.style).unwrap();
assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock));
}
EntityKind::MLine(m) => {
assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock));
assert_eq!(m.def.fill, None);
}
_ => continue,
}
kinds.push(e.kind.type_name());
}
assert_eq!(kinds.len(), 5, "{kinds:?}");
// This drawing's styles are left as they were.
assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1));
assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1));
}
#[test]
fn inline_colors_of_multiline_attributes_are_replaced() {
use cadcraft_engine::doc::Attrib;
let attrib = |contents: &str| -> Attrib {
let at = serde_json::to_value(Vec3::ZERO).unwrap();
serde_json::from_value(json!({
"tag": "T",
"text": {"insert": at, "height": 1.0, "value": "x"},
"multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"},
}))
.unwrap()
};
let mut s = Session::new();
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x"))));
s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part));
let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") };
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap();
s.open_drawing(Drawing::new_imperial(), "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone());
let mut blocks = Vec::new();
for e in members(&s, r["current"].as_str().unwrap()) {
if let EntityKind::Insert(i) = &e.kind {
assert_eq!(i.attribs.iter().map(multiline).collect::<Vec<_>>(), [Some("x".to_string())], "the reference's attribute");
blocks.push(i.block.clone());
}
}
assert_eq!(blocks.len(), 1);
let d = s.doc().unwrap();
let defs: Vec<Option<String>> = d
.block(&blocks[0])
.unwrap()
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(a) => Some(multiline(a)),
_ => None,
})
.collect();
assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block");
}
#[test]
fn edits_after_compare_count_and_objects_keep_their_layers() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let copy: Drawing = s.doc().unwrap().clone();
s.open_drawing(copy, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
// A line drawn after COMPARE ran is only in this drawing.
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
let mut got = contents(&s, r["current"].as_str().unwrap());
got.sort_by_key(|(_, c)| *c == GRAY);
assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}");
// The copies sit in model space on their own layer, not inside a block.
let d = s.doc().unwrap();
assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_))));
}
#[test]
fn copies_drop_associations_to_the_source_objects() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let l = handle_of(&s, "Line");
s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap();
let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None });
assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line");
s.open_drawing(Drawing::new_imperial(), "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
assert_eq!(assoc(s.doc().unwrap()), Some(0));
}
#[test]
fn relative_xref_paths_become_paths_from_the_source_folder() {
let folder = std::env::temp_dir().join("cadcraft-compareexport");
let host = folder.join("host.dxf");
let mut d = Drawing::new_imperial();
let mut x = Block::new("X");
x.xref_path = Some("parts/x.dwg".into());
d.blocks.insert("X".into(), Arc::new(x));
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap();
let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() };
d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap();
let mut b = Block::new("PIC");
b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image)));
d.blocks.insert("PIC".into(), Arc::new(b));
let mut s = Session::new();
s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string()));
s.open_drawing(Drawing::new_imperial(), "Other", None);
s.execute("document.switch", &json!({"index": 1})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
let d = s.doc().unwrap();
let got = d.block("X").unwrap().xref_path.clone().unwrap();
assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg"));
// Images in model space and in blocks too.
let want = folder.join("img").join("a.png");
let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i.path.clone()),
_ => None,
});
let images: Vec<String> = images.collect();
assert_eq!(images.len(), 2);
assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}");
}
#[test]
fn says_when_blocks_of_the_other_drawing_were_left_out() {
let mut other = Drawing::new_imperial();
for i in 0..10_001 {
let name = format!("B{i}");
let mut b = Block::new(&name);
b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0)));
other.blocks.insert(name.clone(), Arc::new(b));
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap();
}
other.handseed = other.handseed.max(0x20_0000);
let mut s = Session::new();
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
assert_eq!(r["truncated"], true, "{}", r["message"]);
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
// The same blocks in this drawing, compared with an empty one: past the limit of recolored
// copies some references keep their blocks' own colors.
let ours = s.docs[1].doc.as_ref().clone();
let mut s = Session::new();
s.open_drawing(ours, "Ours", None);
s.execute("compare", &json!({"document": 0})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
assert_eq!(r["truncated"], true, "{}", r["message"]);
assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]);
}
#[test]
fn says_when_blocks_both_drawings_define_differently_were_left_out() {
// Both drawings define B0 to B10000, each with a line of another length, so past the block
// limit the blocks left are ones this drawing defines differently.
let blocks = |end: f64, first: u64, d: &mut Drawing| {
for i in 0..10_001 {
let name = format!("B{i}");
let mut b = Block::new(&name);
b.entities.push(Entity::new(Handle(first + i), line(end)));
d.blocks.insert(name, Arc::new(b));
}
};
let mut other = Drawing::new_imperial();
blocks(1.0, 0x10_0000, &mut other);
for i in 0..10_001 {
other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap();
}
other.handseed = other.handseed.max(0x20_0000);
let mut s = Session::new();
let mut mine = s.doc().unwrap().clone();
blocks(2.0, 0x30_0000, &mut mine);
mine.handseed = mine.handseed.max(0x40_0000);
s.open_drawing(mine, "Mine", None);
s.open_drawing(other, "Other", None);
s.execute("document.switch", &json!({"index": 1})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
let r = s.execute("compareexport", &json!({})).unwrap();
assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]);
}
#[test]
fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() {
use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH};
// B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past
// the nesting limit.
let mut s = Session::new();
let d = s.doc_mut().unwrap();
let mut drawn = Block::new("*D1");
drawn.entities.push(Entity::new(Handle(0x600), line(30.0)));
d.blocks.insert("*D1".into(), Arc::new(drawn));
let dim = Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(15.0, 5.0, 0.0),
text_mid: Vec3::new(7.0, 5.0, 0.0),
p13: Vec3::ZERO,
p14: Vec3::new(30.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 30.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
for k in 1..=MAX_BLOCK_DEPTH {
let mut b = Block::new(&format!("B{k}"));
let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) };
b.entities.push(Entity::new(Handle(0x700 + k as u64), kind));
d.blocks.insert(b.name.clone(), Arc::new(b));
}
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap();
s.open_drawing(Drawing::new_imperial(), "Other", None);
s.execute("document.switch", &json!({"index": 0})).unwrap();
s.execute("compare", &json!({"document": "Other"})).unwrap();
s.execute("compareexport", &json!({})).unwrap();
let d = s.doc().unwrap();
let dims: Vec<Option<String>> = d
.blocks
.values()
.filter(|b| b.name.starts_with("COMPARE_"))
.flat_map(|b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.block.clone()),
_ => None,
})
.collect::<Vec<_>>()
})
.collect();
// The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s.
assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::<Vec<_>>());
}
#[test]
fn needs_a_comparison_of_the_current_drawing() {
let mut s = Session::new();
assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet");
let mut s = compared();
s.execute("document.switch", &json!({"index": 0})).unwrap();
assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing");
s.execute("document.switch", &json!({"index": 1})).unwrap();
s.execute("compareclose", &json!({})).unwrap();
assert!(s.execute("compareexport", &json!({})).is_err(), "closed");
// Typed at the command line it runs too.
let mut s = compared();
let before = s.docs.len();
s.cmdline("compareexport").unwrap();
assert_eq!(s.docs.len(), before + 1);
}
+29
View File
@@ -162,3 +162,32 @@ fn hostile_nesting_is_bounded() {
assert!(r["blocks"]["LOOP"].as_u64().unwrap() >= 1, "{r}");
assert!(r["blocks"].get("L16").is_none(), "nesting stops at MAX_BLOCK_DEPTH: {r}");
}
/// #1440: COUNT says which objects it scanned, which it counted, and why the rest were skipped.
#[test]
fn self_check_says_what_count_covered() {
let mut s = plates();
put(&mut s, &Space::Model, circle(300.0, 1.0));
let r = s.execute("count", &json!({})).unwrap();
let sc = &r["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!(sc["found"], 4, "three block references and one circle: {r}");
assert_eq!(sc["analyzed"], 3);
assert_eq!(sc["skipped"]["Circle"], json!({ "count": 1, "reason": "not a block reference" }));
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
// Counting the circles compares only circles, and says so.
let h = put(&mut s, &Space::Model, circle(310.0, 1.0));
let r = s.execute("count", &json!({"object": h.hex()})).unwrap();
let sc = &r["selfCheck"];
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(5), json!(2)), "{r}");
assert_eq!(sc["skipped"]["Block Reference"]["reason"], "a different type");
// When nested expansion hits the work cap, the cap and its value are reported.
let mut s = Session::new();
define(&mut s, "LOOP", vec![insert("LOOP", 0.0, 1, 1)]);
put(&mut s, &Space::Model, insert("LOOP", 0.0, 1, 1));
let r: Value = s.execute("count", &json!({"space": "all"})).unwrap();
assert_eq!(r["selfCheck"]["caps"], json!({ "blockDepth": 16 }), "the infinite LOOP hits the depth cap: {r}");
}
+122
View File
@@ -100,6 +100,34 @@ fn new_variables_are_style_overrides_and_read_back() {
assert_eq!(st.dim_linetype, "DASHED");
}
#[test]
fn text_fill_variables_are_style_overrides() {
let mut s = Session::new();
set(&mut s, "DIMTFILL", json!(2));
set(&mut s, "DIMTFILLCLR", json!(3));
let o = s.doc().unwrap().dim_overrides();
assert_eq!(o.get("textFill"), Some(&json!(2)));
linear(&mut s, 10.0);
let (dm, g) = last_dim(&s);
let st = DimStyle::default().with_overrides(&dm.overrides);
assert_eq!((st.text_fill, st.text_fill_color), (2, cadcraft_engine::doc::color::Color::Index(3)));
assert_eq!(g.text_fill.len(), 4, "the new dimension has a text background");
// DIMTFILL takes 0, 1 or 2.
let mut st = DimStyle::default();
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([("DIMTFILL".to_string(), json!(3))])).len(), 1);
// EXPLODE keeps the background as a SOLID in DIMTFILLCLR, before the text.
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
s.execute("explode", &json!({ "handles": [h] })).unwrap();
let d = s.doc().unwrap();
let solid = d.model.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == cadcraft_engine::doc::color::Color::Index(3));
let mtext = d.model.iter().position(|e| matches!(e.kind, EntityKind::MText(_)));
assert!(solid.is_some() && solid < mtext, "{solid:?}, {mtext:?}");
// The arrowheads (the other SOLIDs) come before the background, as the dimension draws them.
let arrows: Vec<usize> =
d.model.iter().enumerate().filter(|(i, e)| matches!(e.kind, EntityKind::Solid(_)) && Some(*i) != solid).map(|(i, _)| i).collect();
assert!(!arrows.is_empty() && arrows.iter().all(|i| Some(*i) < solid), "{arrows:?} before {solid:?}");
}
#[test]
fn fit_moves_text_or_arrows_outside() {
// 0.5 between the extension lines: the text "0.5000" doesn't fit, the arrows (2 × 0.18) do.
@@ -196,6 +224,37 @@ fn text_move_settings() {
assert!((dm.defpt.y - (12.0 - 0.18)).abs() < 1e-9, "{:?}", dm.defpt);
}
#[test]
fn setvar_refuses_the_values_the_style_refuses() {
let mut s = Session::new();
set(&mut s, "DIMTMOVE", json!(1));
let get = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone();
for (name, v) in [
("DIMTMOVE", json!(3)),
("DIMATFIT", json!(7)),
("DIMLWD", json!(500)),
("DIMJOGANG", json!(120)),
("DIMFXL", json!(-1)),
("DIMTXT", json!(0)),
("DIMDEC", json!(300)),
("DIMCLRT", json!("no such color")),
] {
let before = get(&mut s, name);
assert!(s.execute("setvar", &json!({ "name": name, "value": v })).is_err(), "{name} {v}");
assert_eq!(get(&mut s, name), before, "{name} keeps its value");
// DIMSTYLE and DIMOVERRIDE refuse the same value.
let mut st = DimStyle::default();
assert_eq!(st.apply_fields(&serde_json::Map::from_iter([(name.to_string(), v)])).len(), 1, "{name}");
}
assert_eq!(s.doc().unwrap().dim_overrides().get("textMove"), Some(&json!(1)));
// Values in range still go in, typed as text too.
set(&mut s, "DIMTMOVE", json!("2"));
set(&mut s, "DIMCLRT", json!(3));
let o = s.doc().unwrap().dim_overrides();
assert_eq!(o.get("textMove"), Some(&json!(2)));
assert_eq!(o.get("textColor"), serde_json::to_value(cadcraft_engine::doc::color::Color::Index(3)).ok().as_ref(), "{o:?}");
}
#[test]
fn tolerance_justification_angular_zeros_and_arc_symbol() {
let mut s = Session::new();
@@ -262,6 +321,69 @@ fn linetypes_and_lineweights_of_dimension_and_extension_lines() {
assert!(lws.contains(&0.5) && lws.contains(&0.13), "{lws:?}");
}
fn ignored(r: &Value) -> Vec<&str> {
r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default()
}
#[test]
fn dimstyle_and_dimoverride_refuse_negative_whole_numbers() {
let mut s = Session::new();
s.execute("dimstyle", &json!({ "name": "Standard", "DIMTMOVE": 2, "DIMLWD": 50 })).unwrap();
let r = s
.execute(
"dimstyle",
&json!({
"name": "Standard",
"DIMDEC": -1,
"DIMTMOVE": -1,
"DIMLUNIT": "-1",
"DIMTDEC": -0.6,
"DIMADEC": -2,
"DIMALTD": "300",
"DIMLWE": "300",
}),
)
.unwrap();
let ign = ignored(&r);
for k in ["DIMDEC", "DIMTMOVE", "DIMLUNIT", "DIMTDEC", "DIMADEC", "DIMALTD", "DIMLWE"] {
assert!(ign.contains(&k), "{k}: {r}");
}
let st = s.doc().unwrap().dim_style("Standard").unwrap().clone();
let def = DimStyle::default();
assert_eq!((st.decimals, st.text_move, st.linear_unit, st.tol_decimals), (def.decimals, 2, def.linear_unit, def.tol_decimals));
assert_eq!((st.angular_decimals, st.alt_decimals, st.ext_lineweight), (def.angular_decimals, def.alt_decimals, def.ext_lineweight));
// DIMADEC -1 is a documented value (stored as 0); ByLayer lineweights stay -1.
let r = s.execute("dimstyle", &json!({ "name": "Standard", "DIMADEC": -1, "DIMLWD": "ByLayer" })).unwrap();
assert!(r.get("ignored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().dim_style("Standard").unwrap().dim_lineweight, -1);
// DIMOVERRIDE keeps the valid value and leaves the negative one out.
linear(&mut s, 10.0);
let h = s.doc().unwrap().model.last().unwrap().handle.hex();
s.execute("dimoverride", &json!({ "handles": [h], "DIMDEC": -1, "DIMTMOVE": 1 })).unwrap();
let (dm, _) = last_dim(&s);
assert!(!dm.overrides.contains_key("decimals"), "{:?}", dm.overrides);
assert_eq!(dm.overrides.get("textMove"), Some(&json!(1)));
assert!(s.execute("dimoverride", &json!({ "handles": [h], "DIMZIN": -3 })).is_err());
}
#[test]
fn setvar_negative_whole_numbers_do_not_reach_new_dimensions() {
let mut s = Session::new();
for (name, v) in [("DIMDEC", json!(-1)), ("DIMLUNIT", json!("-1")), ("DIMTDEC", json!(-0.6))] {
// The header may hold the value; new dimensions keep the style's own.
let _ = s.execute("setvar", &json!({ "name": name, "value": v }));
}
let o = s.doc().unwrap().dim_overrides();
for f in ["decimals", "linearUnit", "tolDecimals"] {
assert!(!o.contains_key(f), "{f}: {o:?}");
}
linear(&mut s, 10.0);
let (dm, g) = last_dim(&s);
assert_eq!(g.value, "10.0000");
assert!(dm.overrides.is_empty(), "{:?}", dm.overrides);
}
#[test]
fn leaders_use_the_leader_arrow() {
let mut s = Session::new();
+30
View File
@@ -42,6 +42,26 @@ fn gap_tolerance_closes_small_gaps() {
assert!(hatch(&s, &h).associative);
}
#[test]
fn lines_ending_on_an_ellipse_close_a_boundary() {
// #1690: the verticals end on the true ellipse, which the boundary tracer sees as chords
// that pass just below those ends.
let mut s = Session::new();
s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5 })).unwrap();
for x in [1.0f64, 2.0] {
let y = 5.0 * (1.0 - x * x / 100.0).sqrt();
s.execute("line", &json!({ "points": [[x, 8.0], [x, y]] })).unwrap();
}
let top = s.execute("line", &json!({ "points": [[0, 8], [3, 8]] })).unwrap()["handles"][0].clone();
let h = handle(&s.execute("hatch", &json!({ "points": [[1.5, 7]] })).unwrap());
let hh = hatch(&s, &h);
assert_eq!(hh.loops.len(), 1);
assert_eq!(hh.loops[0].sources.len(), 4);
// The associative refill traces the same way: sliding the top edge keeps the hatch.
s.execute("move", &json!({ "handles": [top], "delta": [0.5, 0] })).unwrap();
assert!(hatch(&s, &h).associative);
}
#[test]
fn pattern_origin_options() {
let mut s = Session::new();
@@ -249,3 +269,13 @@ fn hatchedit_refuses_unknown_patterns_and_region_boundaries() {
// Region boundaries need REGION objects; the hatch is left as it was.
assert!(s.execute("hatchedit", &json!({ "handles": [h], "recreate": "region" })).is_err());
}
#[test]
fn multiline_wall_hatch_keeps_the_room_as_an_island() {
// A closed multiline wall: picking inside the wall hatches the band between its outer and
// inner lines; the room the inner lines enclose is an island, not filled.
let mut s = Session::new();
s.execute("mline", &json!({ "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true, "scale": 1, "justification": "zero" })).unwrap();
let h = handle(&s.execute("hatch", &json!({ "points": [[0, 5]], "pattern": "SOLID" })).unwrap());
assert_eq!(hatch(&s, &h).loops.len(), 2, "outer face + the room as an island");
}
+210 -1
View File
@@ -1,4 +1,5 @@
//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line.
//! AREA's Object / Add area / Subtract area, DIST's Multiple points and MEASUREGEOM's Angle and
//! Volume, typed as at the command line.
use cadcraft_engine::Session;
use serde_json::json;
@@ -74,6 +75,56 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
assert!(!has(&log, "Area = "), "{log:?}");
}
#[test]
fn tools_inquiry_radius_asks_for_the_object() {
// Nothing selected: the menu item asks for the arc or circle.
let mut s = Session::new();
s.cmdline("circle 0,0 2").unwrap();
s.start("radius.measure").unwrap();
assert!(s.prompt_text().contains("Select arc or circle"), "{}", s.prompt_text());
s.cmdline("2,0").unwrap();
assert!(has(&s.log, "Radius = 2.0000"), "{:?}", s.log);
assert!(has(&s.log, "Diameter = 4.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
// A selected arc is measured at once.
let mut s = Session::new();
let r = s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap();
s.set_selection(vec![cadcraft_doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()]);
s.start("radius.measure").unwrap();
assert!(has(&s.log, "Radius = 3.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
}
#[test]
fn measuregeom_radius_measures_a_polyline_arc_segment() {
// A line from 0,0 to 10,0, then a half circle of radius 5 to 10,10 through 15,5.
let pline = json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 1}, [10, 10]]});
let mut s = Session::new();
s.execute("pline", &pline).unwrap();
s.cmdline("measuregeom").unwrap();
s.cmdline("r").unwrap();
// A pick on the line segment keeps the prompt.
s.cmdline("5,0").unwrap();
assert!(has(&s.log, "Select an arc, circle or polyline arc."), "{:?}", s.log);
assert!(s.running.is_some());
s.cmdline("15,5").unwrap();
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
assert!(has(&s.log, "Diameter = 10.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
// The same pick from Tools > Inquiry > Radius, and the JSON form with the point on the arc.
let mut s = Session::new();
let h = s.execute("pline", &pline).unwrap()["handle"].clone();
s.start("radius.measure").unwrap();
s.cmdline("15,5").unwrap();
assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log);
let r = s.execute("radius.measure", &json!({"handle": h, "point": [15, 5]})).unwrap();
assert_eq!(r["radius"].as_f64(), Some(5.0), "{r}");
assert!(s.execute("radius.measure", &json!({"handle": h, "point": [5, 0]})).is_err());
assert!(s.execute("radius.measure", &json!({"handle": h})).is_err());
}
#[test]
fn measuregeom_typed_asks_for_the_option_then_the_points() {
// Distance is the default option (#1103: typed, it used to fail asking for `p1`).
@@ -88,6 +139,63 @@ fn measuregeom_typed_asks_for_the_option_then_the_points() {
assert!(has(&log, "Area = 6.0000"), "{log:?}");
}
#[test]
fn measuregeom_angle_measures_an_arc_a_circle_two_lines_and_a_vertex() {
let typed = |s: &mut Session, lines: &[&str]| {
for l in lines {
s.cmdline(l).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
s.log.clone()
};
// Arc: the angle between its endpoints about its center.
let mut s = Session::new();
s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 270})).unwrap();
assert!(has(&typed(&mut s, &["measuregeom", "a", "-2,0"]), "Angle = 270.0000°"));
// Circle: the pick on the circle and a second point, about the center.
let log = typed(&mut Session::new(), &["circle 0,0 2", "measuregeom", "a", "2,0", "0,5"]);
assert!(has(&log, "Angle = 90.0000°"), "{log:?}");
// Two lines that do not meet, and two segments of one polyline.
let mut s = Session::new();
s.execute("line", &json!({"points": [[1, 0], [10, 0]]})).unwrap();
s.execute("line", &json!({"points": [[0, 1], [5, 6]]})).unwrap();
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "2,3"]), "Angle = 45.0000°"), "{:?}", s.log);
let mut s = Session::new();
s.execute("pline", &json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap();
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "10,5"]), "Angle = 90.0000°"), "{:?}", s.log);
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap();
assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "5,5"]), "The lines are parallel."), "{:?}", s.log);
// Enter: a vertex and two endpoints.
let log = typed(&mut Session::new(), &["measuregeom", "a", "", "0,0", "1,0", "-1,1"]);
assert!(has(&log, "Angle = 135.0000°"), "{log:?}");
// Empty space is not an object, and the prompt stays.
let (s, log) = run(&["measuregeom", "a", "50,50"]);
assert!(has(&log, "*Invalid selection*"), "{log:?}");
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
}
#[test]
fn tools_inquiry_angle_asks_for_the_objects() {
let spec = cadcraft_engine::find_command("angle.measure").expect("no command angle.measure");
assert_eq!(spec.menu, ["Tools", "Inquiry", "Angle"]);
let mut s = Session::new();
s.cmdline("circle 0,0 2").unwrap();
s.start("angle.measure").unwrap();
assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text());
for l in ["0,2", "-3,0"] {
s.cmdline(l).unwrap();
}
assert!(has(&s.log, "Angle = 90.0000°"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
}
#[test]
fn area_of_an_ellipse_reports_its_exact_perimeter() {
// References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form
@@ -103,3 +211,104 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() {
let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]);
assert!(has(&log, "Perimeter = 46.0262"), "{log:?}");
}
#[test]
fn measuregeom_volume_multiplies_an_area_by_a_height() {
// Points: a 4 x 3 triangle's area is 6, times a height of 10.
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]);
assert!(has(&log, "Volume = 60.0000"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// Object (the default): a 10 x 10 square polyline, height 2.
let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]);
assert!(has(&log, "Volume = 200.0000"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// A height that is not a distance is asked for again.
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]);
assert!(has(&log, "Requires a distance."), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
// Enter is not a height; two points give the height as their distance.
let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]);
assert!(has(&log, "Requires a height."), "{log:?}");
assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text());
s.cmdline("7,9").unwrap();
assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
// The JSON forms, by points and by handle.
let mut s = Session::new();
let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap();
assert_eq!(r["volume"], json!(60.0));
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap();
assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}");
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err());
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err());
}
#[test]
fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() {
let mut s = Session::new();
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]);
for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] {
assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}");
assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}");
}
// Without `points`, both measure the selected circle.
let r = s.execute("volume.measure", &json!({"height": 1})).unwrap();
assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
let r = s.execute("area", &json!({"points": null})).unwrap();
assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
}
#[test]
fn measuregeom_volume_too_large_to_hold_is_refused() {
// A 2e150 square has an area of 4e300; times 1e10 it is past f64's range.
let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]);
let mut s = Session::new();
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err());
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok());
// Typed, the height is asked for again.
let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]);
assert!(has(&log, "The volume is too large."), "{log:?}");
assert!(!has(&log, "Volume = "), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
// Two volumes that each fit can overflow the running total; the second is asked for again.
let sq = ["0,0", "1,0", "1,1", "0,1", ""];
let mut lines = vec!["measuregeom", "v", "a"];
lines.extend(sq);
lines.push("1e308");
lines.extend(sq);
lines.push("1e308");
let (s, log) = run(&lines);
assert!(has(&log, "The volume is too large."), "{log:?}");
assert!(!has(&log, "Total volume = 0"), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
}
#[test]
fn tools_inquiry_volume_adds_and_subtracts_a_running_total() {
let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure");
assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]);
// Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5.
let mut s = Session::new();
s.cmdline("rectang 20,0 22,2").unwrap();
s.start("volume.measure").unwrap();
for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] {
s.cmdline(l).unwrap();
}
assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log);
assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
// Architectural units show the volume as a number, not as a feet-and-inches length.
let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]);
assert!(has(&log, "Volume = 1200"), "{log:?}");
assert!(!has(&log, "'-"), "{log:?}");
}
+3
View File
@@ -12,10 +12,12 @@ mod attrib_props;
mod attribute_tools;
mod block_keep;
mod block_redefine;
mod blockreplace;
mod break_closed_polyline;
mod break_join_locked_layers;
mod break_keeps_z;
mod burst;
mod compare_export;
mod constraint_geometry;
mod constraint_infer;
mod count;
@@ -108,6 +110,7 @@ mod select_similar;
mod select_underscore_keywords;
mod selection_cycling;
mod selection_modes;
mod setvar_cecolor;
mod setvar_coverage;
mod shapes;
mod snap_annotations;
+40
View File
@@ -29,12 +29,16 @@ fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
r
}
/// Add a circle and turn it into a region, returning the handle of the region's source circle.
fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle {
let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap());
s.execute("region", &json!({"handles": [hd.hex()]})).unwrap();
hd
}
/// REGION turns closed rectangles and circles into regions and refuses an open line; AREA and
/// MASSPROP then report the expected area, perimeter, centroid and moments (a rectangle's and a
/// circle's integrals are known exactly).
#[test]
fn region_from_closed_objects_measures() {
let mut s = Session::new();
@@ -68,6 +72,42 @@ fn region_from_closed_objects_measures() {
assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err());
}
/// #1440: MASSPROP says what it measured and what it skipped, and carries a self-check.
#[test]
fn massprop_self_check_reports_skipped_objects() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap();
let line = s.doc().unwrap().model.iter().last().unwrap().handle;
let m = s.execute("massprop", &json!({"handles": [a.hex(), line.hex()]})).unwrap();
let sc = &m["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(2), json!(1)), "{m}");
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a region" }));
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
}
/// Make `hd` invisible: it no longer counts towards the drawing extents.
fn hide(s: &mut Session, hd: Handle) {
s.doc_mut().unwrap().modify_entity(hd, |e| e.common.visible = false).unwrap();
}
/// #1440 review: with no visible extents (the only region is invisible) there is no drawing bound
/// to compare an area against, so it is not flagged as larger than the extents.
#[test]
fn massprop_does_not_flag_area_without_visible_extents() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
hide(&mut s, a);
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap();
assert_eq!(m["regions"][0]["area"], json!(40.0));
assert_eq!(m["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {m}");
}
/// SUBTRACT cuts a hole where a region lies inside another, and UNION adds an island when a region
/// sits inside that hole; the loops, area and containment follow, and the removed region is gone.
#[test]
fn booleans_on_separate_and_nested_regions() {
// Subtract a circle inside a rectangle: a hole.
+51
View File
@@ -0,0 +1,51 @@
//! `setvar CECOLOR` takes what the `color` command takes: "ByLayer", "ByBlock", a colour name or an
//! index (#1999).
use cadcraft_engine::Session;
use cadcraft_engine::doc::color::{Color, Rgb};
use serde_json::{Value, json};
fn cecolor(s: &mut Session) -> Value {
s.execute("getvar", &json!({ "name": "CECOLOR" })).unwrap()["value"].clone()
}
fn new_line_color(s: &mut Session) -> Color {
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.doc().unwrap().model.last().unwrap().common.color
}
#[test]
fn setvar_cecolor_accepts_names() {
for (value, aci) in [("BYLAYER", 256), ("ByLayer", 256), ("BYBLOCK", 0), ("red", 1), ("250", 250)] {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "CECOLOR", "value": value })).unwrap_or_else(|e| panic!("{value}: {e}"));
assert_eq!(cecolor(&mut s), json!(aci), "{value}");
}
}
#[test]
fn setvar_cecolor_name_replaces_a_true_colour() {
let mut s = Session::new();
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30)));
s.execute("setvar", &json!({ "name": "CECOLOR", "value": "ByLayer" })).unwrap();
assert_eq!(cecolor(&mut s), json!(256));
assert_eq!(new_line_color(&mut s), Color::ByLayer);
}
#[test]
fn setvar_cecolor_number_replaces_a_true_colour() {
let mut s = Session::new();
s.execute("color", &json!({ "color": "10,20,30" })).unwrap();
s.execute("setvar", &json!({ "name": "CECOLOR", "value": 250 })).unwrap();
assert_eq!(new_line_color(&mut s), Color::Index(250));
}
#[test]
fn setvar_cecolor_refuses_what_is_not_a_colour() {
let mut s = Session::new();
for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true)] {
assert!(s.execute("setvar", &json!({ "name": "CECOLOR", "value": bad })).is_err(), "{bad}");
}
assert_eq!(cecolor(&mut s), json!(256));
}
+135 -2
View File
@@ -2,18 +2,32 @@
//! closed shapes in a drawing (#1436).
use cadcraft_engine::Session;
use cadcraft_engine::doc::geom::{PolyVertex, Vec2};
use cadcraft_engine::doc::{Common, EntityKind, LwPolyline, Space};
use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3};
use cadcraft_engine::doc::{Common, EntityKind, Face3d, LwPolyline, Space};
use serde_json::{Value, json};
const EPS: f64 = 1e-9;
/// Add a polyline through `(x, y, bulge)` vertices to model space.
fn pline(s: &mut Session, pts: &[(f64, f64, f64)], closed: bool) {
let vertices = pts.iter().map(|&(x, y, b)| PolyVertex::with_bulge(Vec2::new(x, y), b)).collect();
let k = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false });
s.doc_mut().unwrap().add(&Space::Model, Common::default(), k).unwrap();
}
/// Add a unit 3D face at height `z`; a face is not a closed 2D shape, so SHAPES must skip it.
fn face(s: &mut Session, z: f64) {
let corners = [[0.0, 0.0, z], [1.0, 0.0, z], [1.0, 1.0, z], [0.0, 1.0, z]].map(|c| Vec3::new(c[0], c[1], c[2]));
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Face3d(Face3d { corners, hidden_edges: 0 })).unwrap();
}
/// The last model-space entity, made invisible so it lies outside the drawing extents.
fn hide_last(s: &mut Session) {
let h = s.doc().unwrap().model.iter().last().unwrap().handle;
s.doc_mut().unwrap().modify_entity(h, |e| e.common.visible = false).unwrap();
}
/// Add a closed 10 × 10 square polyline at the origin.
fn square(s: &mut Session) {
pline(s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true);
}
@@ -75,6 +89,8 @@ fn bow_tie_reports_its_crossing() {
near(&x[1], 5.0);
}
/// Two coincident consecutive vertices are reported with their index and distance, while the
/// shape is still measured (the duplicate does not change its area).
#[test]
fn repeated_vertex_is_listed() {
let mut s = Session::new();
@@ -85,6 +101,8 @@ fn repeated_vertex_is_listed() {
near(&sh["area"], 100.0);
}
/// An open polyline whose last vertex repeats its first is treated as closed: one shape, its area
/// measured, and the coincident closing vertex not reported as a duplicate.
#[test]
fn open_polyline_with_matching_ends_is_closed() {
let mut s = Session::new();
@@ -95,6 +113,8 @@ fn open_polyline_with_matching_ends_is_closed() {
assert_eq!(r["shapes"][0]["duplicateVertices"], json!([]));
}
/// A circle is measured exactly, and objects that are not closed shapes (a line, a text, an open
/// polyline) are counted under `skipped` with their reasons instead of polluting the result.
#[test]
fn circle_and_skipped_entities() {
let mut s = Session::new();
@@ -117,6 +137,8 @@ fn circle_and_skipped_entities() {
assert_eq!(sk.values().filter(|v| v["reason"] == "not a closed shape").count(), 2);
}
/// Paging reports only the requested window of shapes, and a malformed option is an error rather
/// than silently falling back to a default.
#[test]
fn paging_and_bad_options() {
let mut s = Session::new();
@@ -131,3 +153,114 @@ fn paging_and_bad_options() {
assert!(s.execute("shapes", &json!({ "limit": "x" })).is_err());
assert!(s.execute("shapes", &json!({ "window": [1, 2] })).is_err());
}
/// #1440: an agent must be told what an analysis covered: found, analyzed and skipped, the caps
/// it hit, and values that cannot be right. The existing keys stay as they were.
#[test]
fn self_check_counts_found_analyzed_and_skipped() {
let mut s = Session::new();
square(&mut s);
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("text", &json!({"at": [0, 0], "text": "NOTE", "height": 0.25})).unwrap();
face(&mut s, 0.0);
let r = shapes(&mut s, json!({}));
// Add-only: the old keys and their meanings are unchanged.
assert_eq!((r["schema"].clone(), r["count"].clone(), r["returned"].clone()), (json!(1), json!(1), json!(1)));
for key in ["offset", "truncated", "shapes", "skipped"] {
assert!(!r[key].is_null(), "{key}: {r}");
}
let sc = &r["selfCheck"];
assert_eq!(sc["schema"], 1);
assert_eq!(sc["found"], 4);
assert_eq!(sc["analyzed"], 1);
assert_eq!(sc["skipped"]["3D Face"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"]["Text"], json!({ "count": 1, "reason": "not a closed shape" }));
assert_eq!(sc["skipped"], r["skipped"], "the self-check repeats the skip map it is built from");
assert_eq!(sc["caps"], json!({}));
assert_eq!(sc["implausible"], json!([]));
let analyzed = sc["analyzed"].as_u64().unwrap();
let skipped: u64 = sc["skipped"].as_object().unwrap().values().map(|v| v["count"].as_u64().unwrap()).sum();
assert_eq!(sc["found"].as_u64().unwrap(), analyzed + skipped);
}
/// #1440: a cap that stopped the analysis is reported with its value — the page `limit` when it
/// truncates, and `crossingSegments` when a shape has more edges than the self-intersection check
/// covers (which then reports no self-intersection) — while an unreached cap stays out of the
/// report.
#[test]
fn self_check_reports_the_caps_it_hits() {
let mut s = Session::new();
for _ in 0..3 {
square(&mut s);
}
let r = shapes(&mut s, json!({ "limit": 1 }));
assert_eq!(r["truncated"], true);
assert_eq!(r["selfCheck"]["caps"], json!({ "limit": 1 }), "the page cap and its value: {r}");
assert_eq!((r["selfCheck"]["found"].clone(), r["selfCheck"]["analyzed"].clone()), (json!(3), json!(3)));
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({}), "a cap that was not reached is not reported");
// A closed polyline with more edges than the self-intersection cap can check.
let zig: Vec<(f64, f64, f64)> = (0..=4_000).map(|i| (f64::from(i), 0.0, 0.0)).collect();
pline(&mut s, &zig, true);
let r = shapes(&mut s, json!({ "limit": 10 }));
assert_eq!(r["selfCheck"]["caps"], json!({ "crossingSegments": 4_000 }), "{r}");
assert_eq!(r["shapes"].as_array().unwrap().last().unwrap()["selfIntersection"], Value::Null);
}
/// #1440: geometry that cannot be right is flagged with the bound it broke — a perimeter past 10×
/// the shape's bounding-box diagonal, and an area larger than the drawing extents (invisible
/// objects lie outside the extents, which is how the test makes an out-of-extents shape).
#[test]
fn self_check_flags_implausible_geometry() {
let mut s = Session::new();
square(&mut s); // visible extents: 10 x 10, area 100
// A sawtooth in the same 10 x 10 box: perimeter about 410, past 10 x the 14.14 diagonal.
let teeth: Vec<(f64, f64, f64)> = (0..=40).map(|i| (f64::from(i) * 0.25, if i % 2 == 0 { 0.0 } else { 10.0 }, 0.0)).collect();
pline(&mut s, &teeth, true);
// Invisible (so outside the drawing extents): 100 x 100, area 10,000.
pline(&mut s, &[(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let perimeter = flags.iter().find(|f| f["field"] == "perimeter").unwrap_or_else(|| panic!("no perimeter flag: {r}"));
assert!(perimeter["reason"].as_str().unwrap().contains("10"), "{perimeter}");
assert_eq!(perimeter["type"], "Polyline");
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["reason"].as_str().unwrap().contains("drawing extents"), "{area}");
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap());
}
/// #1440 review: a border rectangle that fills the drawing extents has an area equal to them in
/// exact arithmetic, so it is not implausible; a shape clearly larger than the extents still is.
#[test]
fn self_check_does_not_flag_an_extents_filling_rectangle() {
let mut s = Session::new();
square(&mut s); // the extents are its own 10 x 10 box, area 100.
let r = shapes(&mut s, json!({}));
assert_eq!(r["selfCheck"]["implausible"], json!([]), "an extents-filling rectangle is not a bug: {r}");
// The same rectangle a millionth larger, invisible so it lies outside the extents: clearly
// past the tolerance, so it is still flagged.
pline(&mut s, &[(0.0, 0.0, 0.0), (10.000001, 0.0, 0.0), (10.000001, 10.000001, 0.0), (0.0, 10.000001, 0.0)], true);
hide_last(&mut s);
let r = shapes(&mut s, json!({}));
let flags = r["selfCheck"]["implausible"].as_array().unwrap();
let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}"));
assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap() * (1.0 + 1e-9), "{area}");
}
/// #1440 review: with no visible extents (the only shape is invisible) there is no drawing bound
/// to compare an area against, so it is not flagged as larger than the extents.
#[test]
fn self_check_has_no_area_bound_without_visible_extents() {
let mut s = Session::new();
square(&mut s);
hide_last(&mut s);
assert!(s.doc().unwrap().extents(&Space::Model).is_empty());
let r = shapes(&mut s, json!({}));
assert_eq!(r["count"], 1);
assert_eq!(r["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {r}");
}

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