Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 1fcf654ea8 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:26:26 -04:00
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
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
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
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
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
81 changed files with 5917 additions and 1542 deletions
Generated
+246 -21
View File
@@ -434,6 +434,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"
@@ -547,7 +553,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -571,7 +577,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -582,14 +588,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -599,7 +605,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -610,14 +616,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"encoding_rs",
"log",
@@ -626,7 +632,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -647,7 +653,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
@@ -657,14 +663,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -674,6 +680,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -684,7 +691,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -696,7 +703,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -710,7 +717,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -733,7 +740,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -868,6 +875,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"
@@ -946,14 +959,28 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
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=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
dependencies = [
"egui",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
]
@@ -1311,7 +1338,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"font-types 0.12.7",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@@ -1427,6 +1454,15 @@ 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"
@@ -1439,6 +1475,15 @@ 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 +1627,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"
@@ -1775,6 +1830,39 @@ dependencies = [
"foldhash",
]
[[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=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
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=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
[[package]]
name = "heapless"
version = "0.8.0"
@@ -1932,6 +2020,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"
@@ -2092,6 +2196,32 @@ dependencies = [
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
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]]
name = "kurbo"
version = "0.13.1"
@@ -2827,6 +2957,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"
@@ -3153,6 +3295,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 +3312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.7",
"once_cell",
]
@@ -3172,7 +3324,7 @@ checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"font-types",
"font-types 0.12.7",
"once_cell",
"yoke",
]
@@ -3461,6 +3613,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 +3766,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 +3943,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"
@@ -3894,6 +4085,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"
@@ -4689,6 +4895,19 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[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,9 +4977,15 @@ 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"
version = "0.13.0"
dependencies = [
"chrono",
"serde_json",
+11 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -25,7 +25,10 @@ cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
# foundation
thiserror = "2"
@@ -50,7 +53,7 @@ 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"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
@@ -58,6 +61,11 @@ egui-wgpu = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
[workspace.lints.rust]
unsafe_code = "forbid"
+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();
+4
View File
@@ -87,3 +87,7 @@ real_name = "Richard Sauer"
display_name = "Richard Sauer"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+21 -3
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,
+1 -1
View File
@@ -897,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 }),
+13 -3
View File
@@ -74,10 +74,12 @@ 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())
@@ -88,12 +90,19 @@ 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.
/// 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();
@@ -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)
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
}
/// The arrowhead block names a dimension's overrides set.
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
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"))
+7 -8
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()),
@@ -1631,10 +1633,9 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
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)
}
@@ -1683,10 +1684,8 @@ impl RectM2 {
}
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))?;
if self.thickness != 0.0 {
let t = self.thickness;
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
}
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
+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);
+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)
}
+128 -9
View File
@@ -401,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"))?;
@@ -415,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()?;
@@ -889,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 => "",
@@ -899,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,
@@ -923,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);
}
@@ -933,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) => {
@@ -953,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."),
}
@@ -980,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();
}
+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),
}
}
+26 -16
View File
@@ -517,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"),
@@ -1397,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 {
@@ -1421,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),
+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> {
+55
View File
@@ -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..."])
@@ -534,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
@@ -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" };
+13
View File
@@ -885,6 +885,19 @@ 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)
}
+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),
}
}
+7
View File
@@ -379,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)?),
+58
View File
@@ -1309,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")
}
+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())
}
+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"]);
}
+880
View File
@@ -0,0 +1,880 @@
//! 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 source = s.doc().unwrap().clone();
let r = s.execute("compareexport", &json!({})).unwrap();
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);
}
+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();
+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:?}");
}
+2
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;
@@ -0,0 +1,79 @@
//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the
//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by
//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular).
use cadcraft_engine::snap::tracking::CursorQuery;
use cadcraft_engine::{Input, Session};
use cadcraft_geom::Vec2;
use serde_json::json;
/// Move the cursor to `raw` at the current prompt, as the canvas does.
fn hover(s: &mut Session, raw: Vec2) -> Vec2 {
let p = s.current_prompt().unwrap();
let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false };
let c = s.snap_cursor(raw, &q).point;
s.cursor = c;
c
}
/// A session with ortho on, object snaps off and a line from 0,0 to 10,0.
fn ortho_session() -> Session {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s
}
/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents.
fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) {
assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");
let c = hover(&mut s, to);
assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}");
s.input(Input::Point(c)).unwrap();
s.input(Input::Enter).unwrap();
for t in ["0,0", "5,0"] {
s.cmdline(t).unwrap();
}
assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message));
let b = s.doc().unwrap().extents(&s.space());
(b.min, b.max)
}
#[test]
fn stretch_implied_window_ignores_ortho() {
let mut s = ortho_session();
s.cmdline("stretch").unwrap();
// Right to left: a crossing window.
s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap();
assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
}
#[test]
fn stretch_crossing_mode_ignores_ortho() {
let mut s = ortho_session();
for t in ["stretch", "c", "8,-1"] {
s.cmdline(t).unwrap();
}
assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5");
}
#[test]
fn rectangle_corner_prompts_ignore_ortho() {
for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap();
s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap();
if script[0] == "mview" {
s.cmdline("layout s Layout1").unwrap();
}
for t in script {
s.cmdline(t).unwrap();
}
let p = s.current_prompt().unwrap();
assert_eq!(p.message, "Specify opposite corner", "{script:?}");
assert!(p.corner, "{script:?}: the opposite corner ignores ortho");
let c = hover(&mut s, Vec2::new(10.0, 4.0));
assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}");
}
}
+8 -286
View File
@@ -1,25 +1,13 @@
//! TrueType subsets for embedding fonts in exported files (PDF): only the glyphs a document
//! uses, plus the composite-glyph components they need. Glyph ids are kept (unused glyphs
//! become empty), so text written with the original ids shows the same glyphs.
//!
//! Embedding follows the font's licence bits (OS/2 `fsType`): fonts marked "restricted
//! licence", "no subsetting" or "bitmap only" are never embedded; callers draw their text as
//! outlines instead. Fonts without TrueType outlines (CFF-flavoured OpenType) aren't subset
//! here either.
//! Whether a font may be embedded as a subset in exported files (PDF), from its licence bits
//! (OS/2 `fsType`): fonts marked "restricted licence", "no subsetting" or "bitmap only" are
//! never embedded; callers draw their text as outlines instead. Fonts without TrueType outlines
//! (CFF-flavoured OpenType) aren't embedded either.
use std::collections::BTreeSet;
use skrifa::instance::{LocationRef, Size};
use skrifa::FontRef;
use skrifa::raw::types::Tag;
use skrifa::{FontRef, MetadataProvider};
use crate::ttf::Face;
/// Largest subset written (bytes).
pub const MAX_SUBSET_BYTES: usize = 32 << 20;
/// Most components followed in one composite glyph, and most glyphs a subset may reach.
const MAX_COMPONENTS: usize = 256;
/// Whether a face may be embedded as a subset.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Embedding {
@@ -35,40 +23,17 @@ pub enum Embedding {
NotTrueType,
}
/// Why a subset couldn't be made.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SubsetError {
/// The font may not be embedded as a subset.
NotAllowed(Embedding),
/// A table is missing or malformed.
Malformed(&'static str),
/// The subset would be larger than [`MAX_SUBSET_BYTES`].
TooLarge,
}
fn font(face: &Face) -> Option<FontRef<'_>> {
FontRef::from_index(&face.bytes, face.index).ok()
}
fn tag(t: &[u8; 4]) -> Tag {
Tag::new(t)
}
fn table<'a>(f: &FontRef<'a>, t: &[u8; 4]) -> Option<&'a [u8]> {
f.table_data(tag(t)).map(|d| d.as_bytes())
f.table_data(Tag::new(t)).map(|d| d.as_bytes())
}
fn u16_at(b: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
}
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
Some(u32::from_be_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
}
/// The embedding permission of a face.
pub fn embedding(face: &Face) -> Embedding {
let Some(f) = font(face) else { return Embedding::NotTrueType };
let Ok(f) = FontRef::from_index(&face.bytes, face.index) else { return Embedding::NotTrueType };
if table(&f, b"glyf").is_none() || table(&f, b"loca").is_none() || table(&f, b"head").is_none() {
return Embedding::NotTrueType;
}
@@ -84,257 +49,14 @@ pub fn embedding(face: &Face) -> Embedding {
}
}
/// Font-wide metrics for a font descriptor, in units of 1/1000 em.
#[derive(Clone, Debug, PartialEq)]
pub struct FontInfo {
/// PostScript name: letters, digits and hyphens only (never empty).
pub postscript_name: String,
/// Glyph bounding box: x min, y min, x max, y max.
pub bbox: [f64; 4],
pub ascent: f64,
pub descent: f64,
pub cap_height: f64,
/// Degrees, counter-clockwise from vertical.
pub italic_angle: f64,
}
/// A finite value or 0.
fn fin(v: f32) -> f64 {
let v = f64::from(v);
if v.is_finite() { v } else { 0.0 }
}
/// The descriptor metrics of a face.
pub fn info(face: &Face) -> Option<FontInfo> {
let f = font(face)?;
let m = f.metrics(Size::new(1000.0), LocationRef::default());
let bbox = m.bounds.map(|b| [fin(b.x_min), fin(b.y_min), fin(b.x_max), fin(b.y_max)]).unwrap_or([0.0, -250.0, 1000.0, 1000.0]);
let name: String = f
.localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
.english_or_first()
.map(|s| s.to_string())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.take(63)
.collect();
Some(FontInfo {
postscript_name: if name.is_empty() { "Font".into() } else { name },
bbox,
ascent: fin(m.ascent),
descent: fin(m.descent),
cap_height: m.cap_height.map(fin).unwrap_or(fin(m.ascent) * 0.72),
italic_angle: fin(m.italic_angle),
})
}
/// The advance width of glyph `gid` in units of 1/1000 em.
pub fn advance(face: &Face, gid: u16) -> f64 {
font(face)
.and_then(|f| f.glyph_metrics(Size::new(1000.0), LocationRef::default()).advance_width(skrifa::GlyphId::new(u32::from(gid))))
.map(fin)
.unwrap_or(0.0)
}
/// Glyph data offsets from `loca`: `n + 1` entries, checked against the `glyf` length.
fn loca_offsets(loca: &[u8], long: bool, n: usize, glyf_len: usize) -> Option<Vec<usize>> {
let mut v = Vec::with_capacity(n + 1);
for i in 0..=n {
let o = if long { u32_at(loca, i * 4)? as usize } else { usize::from(u16_at(loca, i * 2)?) * 2 };
v.push(o.min(glyf_len));
}
Some(v)
}
/// The data of glyph `g` (empty when its range is bad).
fn glyph_data<'a>(glyf: &'a [u8], offsets: &[usize], g: usize) -> &'a [u8] {
match (offsets.get(g), offsets.get(g + 1)) {
(Some(a), Some(b)) if a < b => glyf.get(*a..*b).unwrap_or(&[]),
_ => &[],
}
}
/// The glyph ids a composite glyph is built from.
fn components(data: &[u8]) -> Vec<u16> {
let mut out = Vec::new();
// numberOfContours < 0: a composite.
if u16_at(data, 0).is_none_or(|n| (n as i16) >= 0) {
return out;
}
let mut pos = 10;
while out.len() < MAX_COMPONENTS {
let (Some(flags), Some(gid)) = (u16_at(data, pos), u16_at(data, pos + 2)) else { break };
out.push(gid);
pos += 4 + if flags & 0x0001 != 0 { 4 } else { 2 };
pos += if flags & 0x0008 != 0 {
2
} else if flags & 0x0040 != 0 {
4
} else if flags & 0x0080 != 0 {
8
} else {
0
};
if flags & 0x0020 == 0 {
break;
}
}
out
}
/// The sum of a table's big-endian 32-bit words (zero-padded).
fn checksum(data: &[u8]) -> u32 {
data.chunks(4).fold(0u32, |sum, c| {
let mut w = [0u8; 4];
for (d, s) in w.iter_mut().zip(c) {
*d = *s;
}
sum.wrapping_add(u32::from_be_bytes(w))
})
}
/// A subset of `face` with the glyphs `gids` (and `.notdef` and the components they use). The
/// other glyphs keep their ids but have no outline.
pub fn subset(face: &Face, gids: &BTreeSet<u16>) -> Result<Vec<u8>, SubsetError> {
let allowed = embedding(face);
if allowed != Embedding::Allowed {
return Err(SubsetError::NotAllowed(allowed));
}
let f = font(face).ok_or(SubsetError::Malformed("font"))?;
let head = table(&f, b"head").filter(|h| h.len() >= 54).ok_or(SubsetError::Malformed("head"))?;
let maxp = table(&f, b"maxp").ok_or(SubsetError::Malformed("maxp"))?;
let glyf = table(&f, b"glyf").ok_or(SubsetError::Malformed("glyf"))?;
let loca = table(&f, b"loca").ok_or(SubsetError::Malformed("loca"))?;
let n = usize::from(u16_at(maxp, 4).ok_or(SubsetError::Malformed("maxp"))?);
let long = u16_at(head, 50).ok_or(SubsetError::Malformed("head"))? != 0;
let offsets = loca_offsets(loca, long, n, glyf.len()).ok_or(SubsetError::Malformed("loca"))?;
// The glyphs to keep: asked for, .notdef, and composite components (transitively).
let mut keep: BTreeSet<u16> = BTreeSet::new();
let mut todo: Vec<u16> = std::iter::once(0).chain(gids.iter().copied()).filter(|g| usize::from(*g) < n).collect();
while let Some(g) = todo.pop() {
if !keep.insert(g) {
continue;
}
for c in components(glyph_data(glyf, &offsets, usize::from(g))) {
if usize::from(c) < n && !keep.contains(&c) {
todo.push(c);
}
}
}
let mut new_glyf: Vec<u8> = Vec::new();
let mut new_loca: Vec<u8> = Vec::with_capacity((n + 1) * 4);
for g in 0..n {
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
if u16::try_from(g).is_ok_and(|g| keep.contains(&g)) {
new_glyf.extend_from_slice(glyph_data(glyf, &offsets, g));
while !new_glyf.len().is_multiple_of(4) {
new_glyf.push(0);
}
}
if new_glyf.len() > MAX_SUBSET_BYTES {
return Err(SubsetError::TooLarge);
}
}
new_loca.extend_from_slice(&u32::try_from(new_glyf.len()).map_err(|_| SubsetError::TooLarge)?.to_be_bytes());
let mut new_head = head.to_vec();
// checkSumAdjustment is set once the file is complete; offsets are long now.
new_head.get_mut(8..12).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&[0; 4]);
new_head.get_mut(50..52).ok_or(SubsetError::Malformed("head"))?.copy_from_slice(&1u16.to_be_bytes());
let mut tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca), (*b"maxp", maxp.to_vec())];
for t in [b"cvt ", b"fpgm", b"hhea", b"hmtx", b"prep"] {
if let Some(d) = table(&f, t) {
tables.push((*t, d.to_vec()));
}
}
if !tables.iter().any(|(t, _)| t == b"hhea") || !tables.iter().any(|(t, _)| t == b"hmtx") {
return Err(SubsetError::Malformed("hhea/hmtx"));
}
tables.sort_by_key(|t| t.0);
let out = sfnt(&tables);
if out.len() > MAX_SUBSET_BYTES { Err(SubsetError::TooLarge) } else { Ok(out) }
}
/// Write a TrueType file from `(tag, data)` tables sorted by tag, with checksums and the
/// `head` checksum adjustment.
fn sfnt(tables: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let count = tables.len() as u16;
let mut pow = 1u16;
let mut sel = 0u16;
while pow.saturating_mul(2) <= count {
pow = pow.saturating_mul(2);
sel += 1;
}
let range = pow.saturating_mul(16);
let mut out = Vec::new();
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
for v in [count, range, sel, count.saturating_mul(16).saturating_sub(range)] {
out.extend_from_slice(&v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
let mut head_at = None;
for (t, d) in tables {
out.extend_from_slice(t);
out.extend_from_slice(&checksum(d).to_be_bytes());
out.extend_from_slice(&(offset as u32).to_be_bytes());
out.extend_from_slice(&(d.len() as u32).to_be_bytes());
offset += d.len().div_ceil(4) * 4;
}
for (t, d) in tables {
if t == b"head" {
head_at = Some(out.len());
}
out.extend_from_slice(d);
while !out.len().is_multiple_of(4) {
out.push(0);
}
}
if let Some(h) = head_at {
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
if let Some(slot) = out.get_mut(h + 8..h + 12) {
slot.copy_from_slice(&adj.to_be_bytes());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composite_components_are_followed_and_bounded() {
// numberOfContours = -1, then two components (word args, MORE_COMPONENTS on the first).
let mut g = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
g.extend_from_slice(&[0x00, 0x21, 0x00, 0x05, 0, 0, 0, 0]);
g.extend_from_slice(&[0x00, 0x00, 0x00, 0x07, 0, 0]);
assert_eq!(components(&g), [5, 7]);
// Truncated and endless (always MORE_COMPONENTS) data stay bounded.
assert_eq!(components(&g[..14]), [5]);
let mut endless = vec![0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
for _ in 0..2000 {
endless.extend_from_slice(&[0x00, 0x20, 0x00, 0x01, 0, 0]);
}
assert_eq!(components(&endless).len(), MAX_COMPONENTS);
assert!(components(&[0, 1, 0]).is_empty());
}
#[test]
fn junk_fonts_are_not_embedded() {
let junk = Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 };
assert_eq!(embedding(&junk), Embedding::NotTrueType);
assert!(subset(&junk, &BTreeSet::from([1])).is_err());
assert!(info(&junk).is_none());
assert_eq!(advance(&junk, 3), 0.0);
}
#[test]
fn sfnt_directory_and_checksums() {
let mut head = vec![0u8; 54];
head[12..16].copy_from_slice(&0x5F0F_3CF5u32.to_be_bytes());
let out = sfnt(&[(*b"head", head), (*b"maxp", vec![0, 0, 0x50, 0, 0, 1])]);
assert_eq!(u16_at(&out, 4), Some(2));
assert_eq!(out.len() % 4, 0);
// The whole file sums to the magic number once the adjustment is in.
assert_eq!(checksum(&out), 0xB1B0_AFBA);
assert_eq!(embedding(&Face { bytes: std::sync::Arc::new(Vec::new()), index: 7 }), Embedding::NotTrueType);
}
}
+3
View File
@@ -20,9 +20,12 @@ thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
miniz_oxide = "0.9"
craft-pdf = { workspace = true, features = ["write"] }
quick-xml = { workspace = true }
[dev-dependencies]
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
acadrust = "0.6.5"
tiny-skia = { workspace = true }
# Reads plotted PDFs back.
craft-pdf = { workspace = true, features = ["read"] }
+20 -7
View File
@@ -118,6 +118,10 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
("arrowBlock2", 344, K::Block),
("fixedExtLength", 49, K::Real),
("jogAngle", 50, K::Angle),
// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1, DIMSTYLE). In a
// DIMSTYLE record 70 also holds the record's flags, which come first.
("textFill", 69, K::Int),
("textFillColor", 70, K::Color),
("angularZeroSuppression", 79, K::Int),
("arcSymbol", 90, K::Int),
("textVerticalPos", 145, K::Real),
@@ -143,14 +147,23 @@ pub(crate) const DIMSAH: i32 = 173;
/// DIMPOST's group in DIMSTYLE records.
pub(crate) const DIMPOST: i32 = 3;
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
/// groups an R2000 reader doesn't expect. The reader takes both.
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
/// files are the 2004 format (AC1018).
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMTFILL, DIMTFILLCLR, DIMARCSYM, DIMFXLON,
/// DIMLTYPE, DIMLTEX1, DIMLTEX2. Files of a variable's [`newer_since`] version or later get it as
/// a DIMSTYLE group (R2004, and R2007 for DIMTFILL and DIMTFILLCLR); older files get it in a
/// style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of a
/// group an older reader doesn't expect. The reader takes both.
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 69, 70, 90, 290, 345, 346, 347];
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups, except DIMTFILL and DIMTFILLCLR
/// (see [`newer_since`]): the others came with AutoCAD 2006, whose files are the 2004 format
/// (AC1018).
pub(crate) const NEWER_SINCE: Version = Version::R2004;
/// The first version that has `code` (one of [`NEWER_CODES`]) as a DIMSTYLE group: R2007 for
/// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1), else
/// [`NEWER_SINCE`].
pub(crate) fn newer_since(code: i32) -> Version {
if matches!(code, 69 | 70) { Version::R2007 } else { NEWER_SINCE }
}
/// Xdata list name of [`NEWER_CODES`] values.
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
+6 -1
View File
@@ -1587,7 +1587,12 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
// Every variable with a DIMSTYLE group code; values the style can't take are
// left at their defaults.
let mut fields = serde_json::Map::new();
for tag in tg.iter().take_while(|x| x.code < 1000) {
// The first 70 is the record's flags; a later one is DIMTFILLCLR.
let flags = tg.iter().position(|x| x.code == 70);
for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) {
if Some(i) == flags {
continue;
}
let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue };
if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) {
fields.insert(field.to_string(), v);
+32 -8
View File
@@ -1393,16 +1393,21 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
// dimension's own colour (#1052).
let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() };
let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color));
for (i, l) in g.lines.iter().enumerate() {
let line_entities = |i: usize, l: &[Vec2]| -> Vec<Entity> {
let c = match g.line_roles.get(i) {
Some(cadcraft_render::LineRole::Ext) => &ext_c,
_ => &dim_c,
};
for seg in l.windows(2) {
if let [a, b] = seg {
out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) });
}
}
l.windows(2)
.filter_map(|seg| match seg {
[a, b] => Some(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }),
_ => None,
})
.collect()
};
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) {
out.extend(line_entities(i, l));
}
for t in &g.fills {
if let [a, b, cc] = t.as_slice() {
@@ -1413,6 +1418,20 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
});
}
}
// DIMTFILL 2: the text background in DIMTFILLCLR, 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()
{
out.push(Entity {
handle: Handle(0),
common: in_color(style.text_fill_color),
kind: EntityKind::Solid(Solid { corners: [p0.to3(0.0), p1.to3(0.0), p3.to3(0.0), p2.to3(0.0)] }),
});
}
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) {
out.extend(line_entities(i, l));
}
if !g.value.is_empty() {
out.push(Entity {
handle: Handle(0),
@@ -2290,8 +2309,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.dimval(*code, enc, false);
continue;
}
// Table groups where the file version has them, else xdata.
if version >= dxf_ext::NEWER_SINCE {
// Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR
// are written only when set, as other writers do, so a style without a text
// background has a single 70 (its flags).
if version >= dxf_ext::newer_since(*code) {
if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) {
continue;
}
w.dimval(*code, enc, false);
} else if v.get(*field) != defaults.get(*field) {
newer.i(1070, i64::from(*code));
+360 -382
View File
@@ -1,11 +1,12 @@
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF writer of our own (pure Rust, no file
//! access, so it runs on the web too).
//! Vector PDF plotting (PLOT / EXPORTPDF), written with craft-pdf's PDF writer (krilla; pure
//! Rust, no file access, so it runs on the web too).
//!
//! One page per sheet: model-space extents fitted to a sheet, or a layout at 1:1 (or the given
//! `scale`) on its paper (paper units are millimetres in metric drawings, inches otherwise).
//! Lines are stroked paths (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
//! paper, and line widths come from lineweights when enabled. The page setup's plot style
//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights.
//! Lines are stroked paths, fills are filled paths, colours are RGB with colour 7 printing
//! black on white paper, and line widths come from lineweights when enabled. The page setup's
//! plot style table (or the `plotStyleTable` option) sets the printed colours, linetypes and
//! lineweights.
//!
//! Several sheets go into one file ([`pdf_sheets`], the `layouts` option), each page labelled
//! with its layout name. Drawing layers become PDF layers (optional content groups) that a PDF
@@ -19,6 +20,19 @@ use cadcraft_geom::{Bounds2, Vec2};
use cadcraft_render::{Kind, Sheet, clip, paper};
use serde_json::Value;
use craft_pdf::write::backend as kr;
use craft_pdf::write::{self, Metadata, WriteError, Writer};
use kr::action::LinkAction;
use kr::annotation::{Annotation, LinkAnnotation, Target};
use kr::error::KrillaError;
use kr::geom::{Path, PathBuilder, Point, Rect, Transform};
use kr::num::NormalizedF32;
use kr::optional_content::OcgId;
use kr::page::{PageLabel, PageSettings};
use kr::paint::{Fill, FillRule, LineCap, LineJoin, Stroke};
use kr::surface::Surface;
use kr::text::{GlyphId, KrillaGlyph};
use crate::{IoError, Result};
mod fonts;
@@ -31,9 +45,13 @@ const MAX_SHEET_MM: f64 = 5080.0;
pub const MAX_PAGES: usize = 256;
/// Most PDF layers in one file; objects on further layers print without one.
const MAX_LAYERS: usize = 4096;
/// Longest PDF layer name, in characters.
const MAX_LAYER_NAME: usize = 1000;
/// Most link annotations in one file, and the longest link target (bytes).
const MAX_LINKS: usize = 10_000;
const MAX_URL: usize = 2048;
/// Longest title and page label written (characters).
const MAX_TEXT: usize = 1000;
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
/// model space).
@@ -64,8 +82,8 @@ pub struct PdfOptions {
pub window: Option<Bounds2>,
/// Drawing layers become PDF layers (optional content groups). Default on.
pub layers: bool,
/// With `layers`: objects on layers that are off are included on PDF layers that start
/// hidden (frozen layers are never included). Default off: they aren't plotted.
/// With `layers`: objects on layers that are off are plotted on PDF layers that start
/// hidden (frozen layers are never plotted). Default off: they aren't plotted.
pub include_off_layers: bool,
/// Hyperlinks: an entity's handle and its link target (a URL). The area the entity covers
/// on the sheet becomes a link annotation.
@@ -356,23 +374,121 @@ pub fn pdf_items(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions) ->
if items.len() > MAX_PAGES {
return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));
}
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with them on.
let with_off;
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
let mut c = d.clone();
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
l.on = true;
match write_pdf(d, items, o, o.text_as_geometry) {
// A font the PDF writer can't embed after all, or one that makes it fail internally:
// plot the text as outlines instead. Outlines embed no font, so this runs at most once.
Err(Failure::Write(e)) if !o.text_as_geometry && font_failure(&e) => write_pdf(d, items, o, true).map_err(Failure::into_io),
r => r.map_err(Failure::into_io),
}
}
/// Whether a writer failure may come from embedding a font: a font the writer refused, or a
/// panic inside the writer (caught by `guard`), which a malformed font can cause.
fn font_failure(e: &WriteError) -> bool {
match e {
WriteError::Serialization(e) => matches!(**e, kr::error::KrillaError::Font(..)),
WriteError::BackendPanic(_) => true,
_ => false,
}
}
/// Why writing failed: our own error, or the PDF writer's.
enum Failure {
Io(IoError),
Write(WriteError),
}
impl From<IoError> for Failure {
fn from(e: IoError) -> Self {
Failure::Io(e)
}
}
impl From<WriteError> for Failure {
fn from(e: WriteError) -> Self {
Failure::Write(e)
}
}
impl Failure {
fn into_io(self) -> IoError {
match self {
Failure::Io(e) => e,
Failure::Write(e) => IoError::Format(format!("PDF export failed: {e}")),
}
with_off = c;
&with_off
} else {
d
};
let links = all_links(d, o);
let pages = items.iter().map(|(s, po)| sheet(drawn, s, po, &links)).collect::<Result<Vec<_>>>()?;
let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() };
let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) };
Ok(assemble(d, &pages, &fonts, o, &title))
}
}
/// Write the file, one sheet at a time: each sheet's display list is built, written as its page
/// and dropped before the next.
fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result<Vec<u8>, Failure> {
write::guard(|| {
// Layers that are off plot on PDF layers that start hidden: draw them from a copy with
// them on.
let with_off;
let drawn = if o.layers && o.include_off_layers && d.layers.iter().any(|l| !l.on && !l.frozen) {
let mut c = d.clone();
for l in c.layers.iter_mut().filter(|l| !l.frozen) {
l.on = true;
}
with_off = c;
&with_off
} else {
d
};
let links = all_links(d, o);
let title = if o.title.is_empty() { items.first().map(|(s, _)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() };
let metadata = Metadata {
creator: "CADCraft".into(),
producer: "CADCraft".into(),
title: Some(title.chars().take(MAX_TEXT).collect()),
..Metadata::default()
};
let settings = kr::SerializeSettings {
compress_content_streams: o.compress,
xmp_metadata: false,
enable_tagging: false,
..kr::SerializeSettings::default()
};
let mut writer = Writer::new(settings, metadata);
let mut fonts = fonts::Fonts::new(!text_as_geometry);
let mut groups = Groups::default();
for (s, po) in items {
let page = sheet(drawn, s, po, &links)?;
let doc = writer.document_mut();
// Groups are added to the document, so they must exist before the page starts.
groups.add(doc, d, &page.layers);
write_page(doc, &page, &groups, &mut fonts)?;
}
if items.len() == 1 && !groups.ids.is_empty() {
writer.document_mut().set_optional_content_page_mode(true);
}
Ok(writer.finish()?)
})
}
/// The PDF layers (optional content groups) of a file: one per drawing layer used, shared by
/// all pages, at most [`MAX_LAYERS`].
#[derive(Default)]
struct Groups {
ids: std::collections::HashMap<u32, OcgId>,
}
impl Groups {
/// Add groups for the drawing layers in `layers` that don't have one yet, named after the
/// layer and visible when the layer is on in `d`, the drawing as given (not the copy that
/// turns layers that are off on to plot them).
fn add(&mut self, doc: &mut kr::Document, d: &Drawing, layers: &[u32]) {
for l in layers {
if self.ids.len() >= MAX_LAYERS || self.ids.contains_key(l) {
continue;
}
let Some(layer) = d.layers.get(*l as usize) else { continue };
let name: String = layer.name.chars().take(MAX_LAYER_NAME).collect();
let id = doc.add_optional_content_group(name, layer.on);
self.ids.insert(*l, id);
}
}
}
/// One plotted sheet, ready to be written as a page.
@@ -383,7 +499,8 @@ struct SheetPage {
list: cadcraft_render::DisplayList,
map: Map,
clip_pt: Bounds2,
/// The drawing layers (indices in the layer table) of the content's `/L<i>` marks.
/// The drawing layers (indices in the layer table) drawn on, in order of first use: the
/// candidates for PDF layers.
layers: Vec<u32>,
/// Link annotations: rectangle in points and target.
links: Vec<(Bounds2, String)>,
@@ -556,125 +673,170 @@ pub(crate) fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f6
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
/// A number for a content stream: short, finite, no exponent.
fn num(out: &mut String, v: f64) {
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
let mut s = format!("{v:.3}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" {
s = "0".into();
}
out.push_str(&s);
/// Largest coordinate written (points); a primitive reaching further is clipped to the plot
/// area first. The PDF writer's geometry is `f32`.
const MAX_COORD: f64 = 1.0e7;
/// A point in points as the writer's `f32`, or `None` when it is not finite or out of range.
fn coord(p: Vec2) -> Option<(f32, f32)> {
let ok = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
(ok(p.x) && ok(p.y)).then_some((p.x as f32, p.y as f32))
}
fn pt(out: &mut String, p: Vec2) {
num(out, p.x);
out.push(' ');
num(out, p.y);
/// A rectangle path between two corners (points), if it has an area.
fn rect_path(min: Vec2, max: Vec2) -> Option<Path> {
let (x0, y0) = coord(min)?;
let (x1, y1) = coord(max)?;
let mut pb = PathBuilder::new();
pb.push_rect(Rect::from_ltrb(x0, y0, x1, y1)?);
pb.finish()
}
fn color(out: &mut String, c: Rgb, op: &str) {
for v in [c.0, c.1, c.2] {
num(out, f64::from(v) / 255.0);
out.push(' ');
/// Diameter (points) of the dot a zero-length hairline plots: a hairline has no width of its
/// own, and this is about the thinnest mark that still prints.
const HAIRLINE_DOT_PT: f32 = 0.25;
/// A circle path (points) around `(x, y)`: four cubic arcs.
fn circle_path(x: f32, y: f32, r: f32) -> Option<Path> {
// Control-point distance of a quarter-circle cubic, as a fraction of the radius.
let k = r * 0.552_284_8;
let mut pb = PathBuilder::new();
pb.move_to(x + r, y);
pb.cubic_to(x + r, y + k, x + k, y + r, x, y + r);
pb.cubic_to(x - k, y + r, x - r, y + k, x - r, y);
pb.cubic_to(x - r, y - k, x - k, y - r, x, y - r);
pb.cubic_to(x + k, y - r, x + r, y - k, x + r, y);
pb.close();
pb.finish()
}
/// Write one sheet as a page: its size, label, link annotations and marks.
fn write_page(doc: &mut kr::Document, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
let (w, h) = (p.media.x as f32, p.media.y as f32);
let label = PageLabel::new(None, Some(p.label.chars().take(MAX_TEXT).collect()), None);
let settings = PageSettings::from_wh(w, h).ok_or_else(|| IoError::Format("the sheet has no area".into()))?.with_page_label(label);
let mut page = doc.start_page_with(settings);
for (r, url) in &p.links {
let Some(uri) = uri(url) else { continue };
// Annotation rectangles are given top-down, like the writer's page surface.
let Some(rect) = Rect::from_ltrb(r.min.x as f32, h - r.max.y as f32, r.max.x as f32, h - r.min.y as f32) else { continue };
let link = LinkAnnotation::new(rect, Target::Action(LinkAction::new(uri).into()));
page.add_annotation(Annotation::new_link(link, None));
}
out.push_str(op);
out.push('\n');
}
/// The page's marks; those of primitives on `layers` are marked as optional content
/// (`/OC /L<layer> BDC` … `EMC`).
///
/// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its
/// embedded font (`/F<font>`), placed by a text matrix.
///
/// Transparent primitives are drawn with a graphics state of their alpha (`/T<percent> gs`);
/// the transparencies used are returned for the page's `/ExtGState` (see [`ext_gstates`]). A
/// list without transparency writes no graphics states.
fn content_stream(
list: &cadcraft_render::DisplayList,
map: &Map,
clip_pt: &Bounds2,
layers: &[u32],
texts: &std::collections::HashMap<u32, (usize, &cadcraft_render::PlotText)>,
) -> (String, Vec<u8>) {
let marked: std::collections::HashSet<u32> = layers.iter().copied().collect();
let white = Rgb(255, 255, 255);
let mut c = String::new();
c.push_str("q\n1 J 1 j\n");
let mut surface = page.surface();
// The surface's y axis points down; flip it so we draw in PDF points (y up).
surface.push_transform(&Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, h));
// Clip to the plot area.
pt(&mut c, clip_pt.min);
c.push(' ');
num(&mut c, clip_pt.width());
c.push(' ');
num(&mut c, clip_pt.height());
c.push_str(" re W n\n");
let mut stroke: Option<Rgb> = None;
let mut fill: Option<Rgb> = None;
let mut width: Option<f64> = None;
let mut pending = false;
let flush = |c: &mut String, pending: &mut bool| {
if *pending {
c.push_str("S\n");
*pending = false;
}
};
// The PDF layer (optional content) the marks are in: `/OC /L<i> BDC` … `EMC`.
let mut layer: Option<u32> = None;
// Inside a text object (`BT` … `ET`), with this font set.
let mut text_font: Option<Option<usize>> = None;
// The transparency (percent) marks are drawn with, and those used on the page.
let mut alpha: u8 = 0;
let mut alphas: Vec<u8> = Vec::new();
for (i, p) in list.prims.iter().enumerate() {
let want = list.prim_layers.get(i).copied().filter(|l| marked.contains(l));
let text = u32::try_from(i).ok().and_then(|i| texts.get(&i));
if text_font.is_some() && (text.is_none() || want != layer) {
c.push_str("ET\n");
text_font = None;
}
let mut drawn = Ok(());
if let Some(clip) = rect_path(p.clip_pt.min, p.clip_pt.max) {
surface.push_clip_path(&clip, &FillRule::NonZero);
drawn = marks(&mut surface, p, groups, fonts);
surface.pop();
}
surface.pop();
surface.finish();
page.finish();
drawn
}
/// What consecutive strokes share to be painted as one path: colour, width (points) and
/// transparency (percent).
#[derive(Clone, Copy, PartialEq)]
struct StrokeStyle {
rgb: Rgb,
width: f32,
transparency: u8,
}
/// Opacity of a transparency in percent.
fn opacity(t: u8) -> NormalizedF32 {
NormalizedF32::new(f32::from(100 - t.min(cadcraft_render::MAX_TRANSPARENCY)) / 100.0).unwrap_or(NormalizedF32::ONE)
}
fn paint(c: Rgb) -> kr::paint::Paint {
kr::color::rgb::Color::new(c.0, c.1, c.2).into()
}
fn fill_path(s: &mut Surface<'_>, path: &Path, rgb: Rgb, t: u8) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(t), rule: FillRule::NonZero }));
s.draw_path(path);
}
/// Paint the strokes collected so far as one path.
fn flush(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>) {
let Some((style, pb)) = batch.take() else { return };
let Some(path) = pb.finish() else { return };
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: paint(style.rgb),
width: style.width,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
opacity: opacity(style.transparency),
..Stroke::default()
}));
s.draw_path(&path);
}
/// The PDF layer of the following marks changed from `from` to `to` (`None`: no layer): end
/// the old one's marked content and start the new one's.
fn layer_changed(s: &mut Surface<'_>, from: Option<OcgId>, to: Option<OcgId>) -> std::result::Result<(), Failure> {
if from.is_some() {
s.end_optional_content().map_err(krilla_failure)?;
}
if let Some(id) = to {
s.start_optional_content(id).map_err(krilla_failure)?;
}
Ok(())
}
fn krilla_failure(e: KrillaError) -> Failure {
Failure::Io(IoError::Format(format!("PDF export failed: {e}")))
}
/// The page's marks, clipped to the plot area by the caller. Those of primitives on a layer
/// with a PDF layer in `groups` are marked as its optional content.
///
/// Consecutive strokes with the same colour, width and transparency are one path; a
/// transparent primitive is stroked on its own (one path is painted once), so where
/// primitives overlap they build up, as in PNG plots and on screen. Block contents and
/// viewport contents share their reference's handle, so this goes by primitive, not by object.
/// Each filled primitive is one path of its triangles.
///
/// Primitives that stand for a glyph in an embeddable font (see [`fonts::Fonts`]) are written
/// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one
/// em).
fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> {
let (list, map) = (&p.list, &p.map);
let texts = fonts::texts(list);
let white = Rgb(255, 255, 255);
let mut batch: Option<(StrokeStyle, PathBuilder)> = None;
let mut layer: Option<OcgId> = None;
for (i, prim) in list.prims.iter().enumerate() {
let want = list.prim_layers.get(i).and_then(|l| groups.ids.get(l)).copied();
if want != layer {
flush(&mut c, &mut pending);
if layer.is_some() {
c.push_str("EMC\n");
}
if let Some(l) = want {
let _ = writeln!(c, "/OC /L{l} BDC");
}
flush(s, &mut batch);
layer_changed(s, layer, want)?;
layer = want;
}
let rgb = p.display_rgb(white);
let raw = list.points(p);
let rgb = prim.display_rgb(white);
let raw = list.points(prim);
if raw.iter().any(|q| !q.is_finite()) {
continue;
}
// A wipeout's mask always hides what is under it.
let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
if t != alpha {
flush(&mut c, &mut pending);
let _ = writeln!(c, "/T{t} gs");
if !alphas.contains(&t) {
alphas.push(t);
}
alpha = t;
}
let w = f64::from(p.lw) * PT_PER_MM;
let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) };
let w = f64::from(prim.lw) * PT_PER_MM;
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
match p.kind {
match prim.kind {
Kind::Polyline | Kind::Infinite { .. } => {
let pts: Vec<Vec2> = match p.kind {
let pts: Vec<Vec2> = match prim.kind {
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
(Some(base), Some(dir)) => {
let a = map.pt(*base);
let dir_pt = map.pt(*base + *dir) - a;
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) {
Some((x, y)) => vec![x, y],
None => continue,
}
@@ -686,301 +848,117 @@ fn content_stream(
if pts.len() < 2 {
continue;
}
// A transparent primitive is stroked on its own (one path is painted once), so
// where primitives overlap they build up, as in PNG plots and on screen. Block
// contents and viewport contents share their reference's handle, so this goes
// by primitive, not by object.
if t > 0 {
flush(&mut c, &mut pending);
// With a vertex out of range, keep the pieces inside the plot area, widened so
// a wide stroke just outside it still shows its edge.
let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) };
let style = StrokeStyle { rgb, width: w as f32, transparency: t };
if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) {
flush(s, &mut batch);
}
if stroke != Some(rgb) || width != Some(w) {
flush(&mut c, &mut pending);
if stroke != Some(rgb) {
color(&mut c, rgb, "RG");
stroke = Some(rgb);
for run in runs {
let Some(pts) = run.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
// The writer leaves out a path without extent, so a zero-length line is
// painted as the round-cap dot its stroke would make.
if let Some(&(x, y)) = pts.first()
&& pts.iter().all(|q| *q == (x, y))
{
flush(s, &mut batch);
let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT };
if let Some(path) = circle_path(x, y, d / 2.0) {
fill_path(s, &path, rgb, t);
}
continue;
}
if width != Some(w) {
num(&mut c, w);
c.push_str(" w\n");
width = Some(w);
let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new()));
for (j, (x, y)) in pts.iter().enumerate() {
if j == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
}
}
for (i, q) in pts.iter().enumerate() {
pt(&mut c, *q);
c.push_str(if i == 0 { " m\n" } else { " l\n" });
}
pending = true;
}
Kind::Tris | Kind::Mask => {
flush(&mut c, &mut pending);
flush(s, &mut batch);
// A wipeout's mask is paper white.
let rgb = if p.kind == Kind::Mask { white } else { rgb };
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
if let Some((font, t)) = text {
glyph(&mut c, &mut text_font, *font, t, map);
let rgb = if prim.kind == Kind::Mask { white } else { rgb };
if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i))
&& let Some(font) = fonts.font(&text.glyph.face)
{
glyph(s, font, text, map, rgb, t);
continue;
}
let mut any = false;
for t in raw.as_chunks::<3>().0 {
for (i, q) in t.iter().enumerate() {
pt(&mut c, map.pt(*q));
c.push_str(if i == 0 { " m\n" } else { " l\n" });
let mapped: Vec<Vec2> = raw.iter().map(|q| map.pt(*q)).collect();
// With a vertex out of range, keep the parts inside the plot area.
let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) };
let mut pb = PathBuilder::new();
for tri in tris.as_chunks::<3>().0 {
let Some(v) = tri.iter().map(|q| coord(*q)).collect::<Option<Vec<_>>>() else { continue };
for (j, (x, y)) in v.iter().enumerate() {
if j == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
}
c.push_str("h\n");
any = true;
pb.close();
}
if any {
c.push_str("f\n");
if let Some(path) = pb.finish() {
fill_path(s, &path, rgb, t);
}
}
Kind::Point => {
let Some(q) = raw.first() else { continue };
flush(&mut c, &mut pending);
if fill != Some(rgb) {
color(&mut c, rgb, "rg");
fill = Some(rgb);
}
flush(s, &mut batch);
let half = (w.max(0.5)) / 2.0;
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
c.push(' ');
num(&mut c, half * 2.0);
c.push(' ');
num(&mut c, half * 2.0);
c.push_str(" re f\n");
let c = map.pt(*q);
if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) {
fill_path(s, &path, rgb, t);
}
}
}
}
flush(&mut c, &mut pending);
if text_font.is_some() {
c.push_str("ET\n");
}
if layer.is_some() {
c.push_str("EMC\n");
}
c.push_str("Q\n");
(c, alphas)
flush(s, &mut batch);
layer_changed(s, layer, None)
}
/// The page resources' graphics states for transparencies `alphas` (percent): `/T<percent>`
/// sets the stroke and fill alpha (`/CA`, `/ca`). Empty when there are none.
fn ext_gstates(alphas: &[u8]) -> String {
if alphas.is_empty() {
return String::new();
}
let mut out = String::from(" /ExtGState <<");
for t in alphas {
let mut a = String::new();
num(&mut a, f64::from(100 - u32::from((*t).min(cadcraft_render::MAX_TRANSPARENCY))) / 100.0);
let _ = write!(out, " /T{t} << /Type /ExtGState /CA {a} /ca {a} >>");
}
out.push_str(" >>");
out
}
/// Write one glyph as text: opens a text object and sets the font when needed, then places the
/// glyph with a text matrix (font size 1: the matrix axes are one em).
fn glyph(c: &mut String, text_font: &mut Option<Option<usize>>, font: usize, t: &cadcraft_render::PlotText, map: &Map) {
if text_font.is_none() {
c.push_str("BT\n");
*text_font = Some(None);
}
if *text_font != Some(Some(font)) {
let _ = writeln!(c, "/F{font} 1 Tf");
*text_font = Some(Some(font));
}
let s = map.s * map.k;
/// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one
/// em along each axis). A glyph whose matrix is out of range or collapses is left out.
fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) {
let k = map.s * map.k;
let g = &t.glyph;
for v in [g.x.x * s, g.x.y * s, g.y.x * s, g.y.y * s] {
num(c, v);
c.push(' ');
let (Some((a, b)), Some((c, d)), Some((e, f))) = (coord(g.x * k), coord(g.y * k), coord(map.pt(g.origin))) else { return };
let det = a * d - b * c;
if !det.is_finite() || det == 0.0 {
return;
}
pt(c, map.pt(g.origin));
let _ = writeln!(c, " Tm <{:04X}> Tj", g.gid);
let text = g.ch.to_string();
let glyphs = [KrillaGlyph::new(GlyphId::new(u32::from(g.gid)), 0.0, 0.0, 0.0, 0.0, 0..text.len(), None)];
// The writer sets glyphs upright on a y-down surface: flip the em's y axis back.
s.push_transform(&Transform::from_row(a, b, -c, -d, e, f));
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint(rgb), opacity: opacity(transparency), rule: FillRule::NonZero }));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, 1.0, false);
s.pop();
}
/// A PDF text string: a literal with `(`, `)` and `\` escaped when it is printable ASCII,
/// else UTF-16BE with a byte order mark (hex). At most 1000 characters.
fn pdf_string(s: &str) -> String {
let s: String = s.chars().take(1000).collect();
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
let mut out = String::from("(");
for ch in s.chars() {
if matches!(ch, '(' | ')' | '\\') {
out.push('\\');
}
out.push(ch);
}
out.push(')');
return out;
}
let mut out = String::from("<FEFF");
for u in s.encode_utf16() {
let _ = write!(out, "{u:04X}");
}
out.push('>');
out
}
/// A link target as a PDF URI string: 7-bit ASCII (other bytes percent-encoded), `(`, `)` and
/// `\` escaped. `None` for script and data URLs, which a plot never carries.
fn uri_string(url: &str) -> Option<String> {
/// A link target as a URI: 7-bit printable ASCII, other bytes percent-encoded. `None` for
/// script and data URLs, which a plot never carries.
fn uri(url: &str) -> Option<String> {
let url = url.trim();
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
if url.is_empty() || url.len() > MAX_URL || matches!(scheme.as_str(), "javascript" | "vbscript" | "data") {
return None;
}
let mut out = String::from("(");
let mut out = String::with_capacity(url.len());
for b in url.bytes() {
match b {
b'(' | b')' | b'\\' => {
out.push('\\');
out.push(char::from(b));
}
0x21..=0x7E => out.push(char::from(b)),
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out.push(')');
Some(out)
}
/// A stream object body, Flate-compressed when asked.
fn stream(data: &[u8], compress: bool, extra: &str) -> Vec<u8> {
let (data, filter) = if compress { (miniz_oxide::deflate::compress_to_vec_zlib(data, 6), " /Filter /FlateDecode") } else { (data.to_vec(), "") };
let mut out = format!("<< /Length {}{filter}{extra} >>\nstream\n", data.len()).into_bytes();
out.extend_from_slice(&data);
out.extend_from_slice(b"\nendstream");
out
}
/// PDF objects being written: bodies by object number - 1.
#[derive(Default)]
struct Objects {
bodies: Vec<Vec<u8>>,
}
impl Objects {
/// Reserve an object number (its body is set later).
fn reserve(&mut self) -> usize {
self.bodies.push(Vec::new());
self.bodies.len()
}
fn set(&mut self, n: usize, body: impl Into<Vec<u8>>) {
if let Some(b) = n.checked_sub(1).and_then(|i| self.bodies.get_mut(i)) {
*b = body.into();
}
}
fn add(&mut self, body: impl Into<Vec<u8>>) -> usize {
let n = self.reserve();
self.set(n, body);
n
}
}
/// `n 0 R` references, space-separated.
fn refs(ids: impl IntoIterator<Item = usize>) -> String {
ids.into_iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ")
}
/// Assemble the file: catalog, page tree, pages with their content streams and link
/// annotations, the PDF layers (one optional content group per drawing layer used, named
/// like it), page labels, document information, cross-reference table and trailer.
fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptions, title: &str) -> Vec<u8> {
let mut objs = Objects::default();
let catalog = objs.reserve();
let tree = objs.reserve();
// Optional content groups shared by all pages, by layer index: (layer, object).
let mut ocg_ids: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
let mut ocgs: Vec<(u32, usize)> = Vec::new();
let mut kids = Vec::new();
// Page dictionaries are completed once the fonts are written: (page, its fonts, the rest).
let mut page_dicts: Vec<(usize, Vec<usize>, String, String, String)> = Vec::new();
for p in pages {
let page = objs.reserve();
let texts = fonts.texts(&p.list);
let (marks, alphas) = content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts);
let content = objs.add(stream(marks.as_bytes(), o.compress, ""));
let mut used: Vec<usize> = texts.values().map(|(f, _)| *f).collect();
used.sort_unstable();
used.dedup();
let mut props = String::new();
for l in &p.layers {
let Some(layer) = d.layers.get(*l as usize) else { continue };
let id = match ocg_ids.get(l) {
Some(id) => *id,
None => {
let id = objs.add(format!("<< /Type /OCG /Name {} >>", pdf_string(&layer.name)));
ocg_ids.insert(*l, id);
ocgs.push((*l, id));
id
}
};
let _ = write!(props, " /L{l} {id} 0 R");
}
let mut annots = Vec::new();
for (r, url) in &p.links {
let Some(uri) = uri_string(url) else { continue };
let mut rect = String::new();
pt(&mut rect, r.min);
rect.push(' ');
pt(&mut rect, r.max);
annots.push(objs.add(format!("<< /Type /Annot /Subtype /Link /Rect [{rect}] /Border [0 0 0] /A << /S /URI /URI {uri} >> >>")));
}
let mut mb = String::new();
pt(&mut mb, p.media);
let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) };
page_dicts.push((page, used, props, ext_gstates(&alphas), format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}")));
kids.push(page);
}
let font_ids = if fonts.is_empty() { Vec::new() } else { fonts.write(&mut objs, o.compress) };
for (page, used, props, gstates, rest) in page_dicts {
let mut res = String::new();
if !props.is_empty() {
let _ = write!(res, " /Properties <<{props} >>");
}
res.push_str(&gstates);
let f: Vec<String> = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect();
if !f.is_empty() {
let _ = write!(res, " /Font << {} >>", f.join(" "));
}
objs.set(page, format!("<< /Type /Page /Parent {tree} 0 R /Resources <<{res} >> {rest} >>"));
}
objs.set(tree, format!("<< /Type /Pages /Kids [{}] /Count {} >>", refs(kids.iter().copied()), kids.len()));
let mut cat = format!("<< /Type /Catalog /Pages {tree} 0 R");
if !ocgs.is_empty() {
// Optional content needs PDF 1.5; the catalog's version overrides the header's.
let off: Vec<usize> = ocgs.iter().filter(|(l, _)| d.layers.get(*l as usize).is_some_and(|l| !l.on)).map(|(_, id)| *id).collect();
let all = refs(ocgs.iter().map(|(_, id)| *id));
let _ = write!(cat, " /Version /1.5 /OCProperties << /OCGs [{all}] /D << /Name (Layers) /Order [{all}] /OFF [{}] >> >>", refs(off));
if pages.len() == 1 {
cat.push_str(" /PageMode /UseOC");
}
}
let labels: Vec<String> = pages.iter().enumerate().map(|(i, p)| format!("{i} << /P {} >>", pdf_string(&p.label))).collect();
let _ = write!(cat, " /PageLabels << /Nums [{}] >> >>", labels.join(" "));
objs.set(catalog, cat);
let info = objs.add(format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title)));
let size: usize = objs.bodies.iter().map(Vec::len).sum();
let mut out: Vec<u8> = Vec::with_capacity(size + 64 * objs.bodies.len() + 256);
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
let mut offsets = Vec::with_capacity(objs.bodies.len());
for (i, body) in objs.bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1);
for o in &offsets {
let _ = writeln!(x, "{o:010} 00000 n ");
}
let _ = write!(x, "trailer\n<< /Size {} /Root {catalog} 0 R /Info {info} 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1);
out.extend_from_slice(x.as_bytes());
out
}
+39 -150
View File
@@ -1,25 +1,22 @@
//! Real text in PDF plots: the outline-font glyphs a plot draws ([`DisplayList::texts`]) are
//! written as text in embedded TrueType subsets, so the text can be selected, searched and
//! copied. Each font is a `Type0` font with `Identity-H` encoding over a `CIDFontType2`
//! descendant whose CIDs are the glyph ids (`/CIDToGIDMap /Identity`), with a `ToUnicode`
//! map for copying and searching.
//! written as text in their font, which the PDF writer embeds as a subset with a `ToUnicode`
//! map, so the text can be selected, searched and copied.
//!
//! Fonts whose licence bits forbid embedding a subset, fonts without TrueType outlines, and
//! anything past the caps on fonts and font data stay as filled outlines.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write as _;
use std::collections::HashMap;
use cadcraft_fonts::subset::{self, FontInfo};
use cadcraft_fonts::subset::{self, Embedding};
use cadcraft_fonts::ttf::Face;
use cadcraft_render::{DisplayList, PlotText};
use super::{Objects, num, refs, stream};
use craft_pdf::write::backend::text::Font;
/// Most embedded fonts in one file.
const MAX_FONTS: usize = 64;
/// Most embedded font data in one file (uncompressed bytes).
const MAX_FONT_BYTES: usize = 64 << 20;
/// Most font file data embedded from in one file (bytes of the font files the subsets are
/// taken from).
const MAX_FONT_BYTES: usize = 256 << 20;
/// A face's identity: its loaded file and face index.
type FaceKey = (usize, usize, u32);
@@ -28,153 +25,45 @@ fn key(f: &Face) -> FaceKey {
(f.bytes.as_ptr() as usize, f.bytes.len(), f.index)
}
/// An embedded font.
struct Font {
face: Face,
/// Glyphs used, and the character each shows.
chars: BTreeMap<u16, char>,
/// The subset file.
data: Vec<u8>,
info: FontInfo,
}
/// The fonts of a document: those that can be embedded, by face.
#[derive(Default)]
/// The fonts of a document, decided face by face as glyphs are written.
pub(super) struct Fonts {
fonts: Vec<Font>,
by_face: HashMap<FaceKey, Option<usize>>,
/// Off: every glyph stays an outline.
enabled: bool,
/// Each face seen, with its font when it is embedded. The face is kept so its key isn't
/// reused by another file loaded at the same address.
by_face: HashMap<FaceKey, (Face, Option<Font>)>,
count: usize,
bytes: usize,
}
impl Fonts {
/// Collect the glyphs of every plotted text glyph and subset their fonts.
pub(super) fn collect<'a>(lists: impl IntoIterator<Item = &'a DisplayList>) -> Fonts {
let mut used: Vec<(Face, BTreeMap<u16, char>)> = Vec::new();
let mut index: HashMap<FaceKey, usize> = HashMap::new();
for list in lists {
for t in usable(list) {
let k = key(&t.glyph.face);
let i = match index.get(&k) {
Some(i) => *i,
None => {
index.insert(k, used.len());
used.push((t.glyph.face.clone(), BTreeMap::new()));
used.len() - 1
}
};
if let Some((_, chars)) = used.get_mut(i) {
chars.entry(t.glyph.gid).or_insert(t.glyph.ch);
}
}
pub(super) fn new(enabled: bool) -> Fonts {
Fonts { enabled, by_face: HashMap::new(), count: 0, bytes: 0 }
}
/// The font a glyph of `face` is written in; `None`: draw its outline.
pub(super) fn font(&mut self, face: &Face) -> Option<Font> {
if !self.enabled {
return None;
}
let mut out = Fonts::default();
let mut total = 0usize;
for (face, chars) in used {
let k = key(&face);
let font = (out.fonts.len() < MAX_FONTS)
.then(|| subset::subset(&face, &chars.keys().copied().collect::<BTreeSet<u16>>()).ok())
.flatten()
.filter(|data| total.saturating_add(data.len()) <= MAX_FONT_BYTES)
.and_then(|data| Some(Font { info: subset::info(&face)?, face, chars, data }));
match font {
Some(f) => {
total = total.saturating_add(f.data.len());
out.by_face.insert(k, Some(out.fonts.len()));
out.fonts.push(f);
}
None => {
out.by_face.insert(k, None);
}
}
let k = key(face);
if let Some((_, f)) = self.by_face.get(&k) {
return f.clone();
}
out
}
pub(super) fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
/// The embedded font (index) a glyph is written in; `None`: draw its outline.
fn font_of(&self, t: &PlotText) -> Option<usize> {
self.by_face.get(&key(&t.glyph.face)).copied().flatten()
}
/// The plotted glyphs of `list` written as text: outline primitive → (font, glyph).
pub(super) fn texts<'a>(&self, list: &'a DisplayList) -> HashMap<u32, (usize, &'a PlotText)> {
usable(list).filter_map(|t| Some((t.prim, (self.font_of(t)?, t)))).collect()
}
/// Write the font objects; returns each font's `Type0` object, by font index.
pub(super) fn write(&self, objs: &mut Objects, compress: bool) -> Vec<usize> {
self.fonts.iter().map(|f| write_font(objs, f, compress)).collect()
}
}
/// The text glyphs of a list that stand for a primitive of it.
fn usable(list: &DisplayList) -> impl Iterator<Item = &PlotText> {
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0)
}
/// Six capital letters naming a subset (PDF subset fonts are `ABCDEF+Name`), from its glyphs.
fn subset_tag(f: &Font) -> String {
let mut h: u64 = 0xCBF2_9CE4_8422_2325;
for b in f.info.postscript_name.bytes().chain(f.chars.keys().flat_map(|g| g.to_be_bytes())) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01B3);
}
(0..6).map(|i| char::from(b'A' + ((h >> (i * 8)) % 26) as u8)).collect()
}
fn write_font(objs: &mut Objects, f: &Font, compress: bool) -> usize {
let name = format!("{}+{}", subset_tag(f), f.info.postscript_name);
let file = objs.add(stream(&f.data, compress, &format!(" /Length1 {}", f.data.len())));
let mut bbox = String::new();
for (i, v) in f.info.bbox.iter().enumerate() {
if i > 0 {
bbox.push(' ');
let len = face.bytes.len();
let font = (self.count < MAX_FONTS && self.bytes.saturating_add(len) <= MAX_FONT_BYTES && subset::embedding(face) == Embedding::Allowed)
.then(|| Font::new(face.bytes.clone().into(), face.index))
.flatten();
if font.is_some() {
self.count += 1;
self.bytes = self.bytes.saturating_add(len);
}
num(&mut bbox, v.round());
self.by_face.insert(k, (face.clone(), font.clone()));
font
}
let mut metrics = String::new();
for (k, v) in [("Ascent", f.info.ascent), ("Descent", f.info.descent), ("CapHeight", f.info.cap_height), ("ItalicAngle", f.info.italic_angle)] {
let _ = write!(metrics, " /{k} ");
num(&mut metrics, v.round());
}
let descriptor =
objs.add(format!("<< /Type /FontDescriptor /FontName /{name} /Flags 4 /FontBBox [{bbox}]{metrics} /StemV 80 /FontFile2 {file} 0 R >>"));
let mut widths = String::new();
for g in f.chars.keys() {
let _ = write!(widths, "{g} [");
num(&mut widths, subset::advance(&f.face, *g).round());
widths.push_str("] ");
}
let cid = objs.add(format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
widths.trim_end()
));
let to_unicode = objs.add(stream(to_unicode(&f.chars).as_bytes(), compress, ""));
objs.add(format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{}] /ToUnicode {to_unicode} 0 R >>",
refs([cid])
))
}
/// A `ToUnicode` CMap from glyph ids (2-byte codes) to the characters they show.
fn to_unicode(chars: &BTreeMap<u16, char>) -> String {
let mut s = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
let entries: Vec<(&u16, &char)> = chars.iter().collect();
// At most 100 entries per block.
for block in entries.chunks(100) {
let _ = writeln!(s, "{} beginbfchar", block.len());
for (g, c) in block {
let _ = write!(s, "<{g:04X}> <");
for u in c.encode_utf16(&mut [0; 2]) {
let _ = write!(s, "{u:04X}");
}
s.push_str(">\n");
}
s.push_str("endbfchar\n");
}
s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
s
/// The text glyphs of a list that stand for a primitive of it, by primitive.
pub(super) fn texts(list: &DisplayList) -> HashMap<u32, &PlotText> {
list.texts.iter().filter(|t| (t.prim as usize) < list.prims.len() && t.glyph.gid != 0).map(|t| (t.prim, t)).collect()
}
+102 -62
View File
@@ -4,6 +4,9 @@ use cadcraft_geom::{PolyVertex, Vec2, Vec3};
use crate::*;
#[path = "../tests/support/pdf_read.rs"]
mod pdf_read;
fn sample() -> Drawing {
let mut d = Drawing::new_imperial();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
@@ -418,50 +421,22 @@ fn hostile_export_window_is_an_error() {
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
/// content.
fn check_pdf(bytes: &[u8]) -> String {
assert!(bytes.starts_with(b"%PDF-1.4"));
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
assert!(tail.trim_end().ends_with("%%EOF"));
let sx = rfind(b"startxref\n").unwrap();
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
assert!(bytes[xref_off..].starts_with(b"xref\n"));
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
let mut lines = xref.lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
assert_eq!(count, objs + 1, "xref size = objects + free entry");
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
let entries: Vec<&str> = lines.take(count).collect();
assert!(entries[0].starts_with("0000000000 65535 f"));
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
}
assert!(xref.contains(&format!("/Size {count}")));
// The content stream's /Length matches its data.
let sp = find(b"/Length ").unwrap();
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = find(b"stream\n").unwrap() + 7;
assert!(bytes[start + len..].starts_with(b"\nendstream"));
let compressed = find(b"/FlateDecode").is_some();
let data = &bytes[start..start + len];
if compressed {
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
} else {
String::from_utf8(data.to_vec()).unwrap()
}
assert!(String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).trim_end().ends_with("%%EOF"));
let doc = pdf_read::read(bytes);
assert!(!doc.pages.is_empty());
doc.content().to_string()
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
let text = String::from_utf8_lossy(bytes).to_string();
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
(v[0], v[1])
pdf_read::read(bytes).media()
}
/// The line widths (`w`) the content sets.
fn widths(content: &str) -> Vec<f64> {
pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect()
}
#[test]
@@ -471,16 +446,17 @@ fn pdf_model_fitted_to_sheet() {
let content = check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
// Walls layer is red; colour 7 prints black.
assert!(content.contains("1 0 0 RG"));
assert!(content.contains("0 0 0 RG"));
// Uncompressed output is plain text and also valid.
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
let c2 = check_pdf(&raw);
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert!(!pdf_read::read(&raw).pages[0].compressed);
assert!(pdf_read::read(&bytes).pages[0].compressed);
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(c2.contains("re W n"));
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
@@ -513,11 +489,12 @@ fn pdf_layout_one_to_one_with_viewport() {
// ANSI A landscape (inches → points).
assert_eq!(media_box(&bytes), (792.0, 612.0));
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
let corner = |o: &pdf_read::Op| matches!(o.op.as_str(), "m" | "l") && (o.nums[0] - 72.0).abs() < 1e-6 && (o.nums[1] - 72.0).abs() < 1e-6;
assert!(pdf_read::drawn(&content).iter().any(corner), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(content.contains("0.709 w"));
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(check_pdf(&off).contains("0 w"));
assert!(widths(&check_pdf(&off)).contains(&0.0));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
@@ -2438,15 +2415,16 @@ fn pdf_plots_transparency_only_when_asked() {
};
let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap();
let content = check_pdf(&on);
let text = String::from_utf8_lossy(&on).to_string();
assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}");
// One graphics state: fill alpha 0.5 (strokes stay opaque).
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
// The line is opaque, the solid at half alpha.
assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}");
let ops = pdf_read::ops(&content);
assert!(ops.windows(2).any(|w| w[0].op == "gs" && w[1].op == "rg" && w[1].nums == [1.0, 0.0, 0.0]), "{content}");
assert_eq!(pdf_read::count(&content, "gs"), 1, "{content}");
// Off (the default, and explicitly): no graphics states, and the same bytes either way.
let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap();
assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap());
let off_text = String::from_utf8_lossy(&off).to_string();
assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n"));
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
// On, but nothing transparent: the same bytes as off.
let opaque = transparent_over_line(0);
assert_eq!(
@@ -2465,13 +2443,13 @@ fn layout_page_setup_plot_transparency_is_used() {
let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() };
d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap();
let raw = serde_json::json!({"compress": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState"));
let alphas = |d: &Drawing, o: &serde_json::Value| pdf_read::read(&plot(d, &paper, o).unwrap()).pages[0].alphas.clone();
assert!(alphas(&d, &raw).is_empty());
d.layouts[0].page.plot_transparency = true;
let text = String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).to_string();
assert!(text.contains("/T90 << /Type /ExtGState /CA 0.1 /ca 0.1 >>"), "{text}");
let on = alphas(&d, &raw);
assert!(on.iter().any(|(_, f)| (f - 0.1).abs() < 1e-6), "the faint solid fills at alpha 0.1: {on:?}");
// The plot's own option wins over the page setup.
let off = serde_json::json!({"compress": false, "plotTransparency": false});
assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState"));
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
}
#[test]
@@ -2526,11 +2504,14 @@ fn pdf_strokes_overlapping_transparent_objects_one_by_one() {
};
// 50 %: each line is its own stroke, so they build up where they cross.
let glass = content(&crossing(50), true);
assert!(glass.contains("/T50 gs"), "{glass}");
assert_eq!(glass.matches("S\n").count(), 2, "{glass}");
assert!(pdf_read::count(&glass, "gs") > 0, "{glass}");
let page =
pdf_read::read(&plot(&crossing(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()).pages.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&glass, "S"), 2, "{glass}");
// Opaque: one path as before, whether the option is on or off.
let opaque = content(&crossing(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing(0), false));
// Transparency off: the transparent layer plots as before too.
assert_eq!(content(&crossing(50), false), opaque);
@@ -2567,11 +2548,15 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() {
check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap())
};
let half = content(&crossing_block(50), true);
assert!(half.contains("/T50 gs"), "{half}");
assert_eq!(half.matches("S\n").count(), 2, "{half}");
assert!(pdf_read::count(&half, "gs") > 0, "{half}");
let page = pdf_read::read(&plot(&crossing_block(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap())
.pages
.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&half, "S"), 2, "{half}");
// Opaque: one path, the same with the option on or off.
let opaque = content(&crossing_block(0), true);
assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}");
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing_block(0), false));
assert_eq!(content(&crossing_block(50), false), opaque);
}
@@ -2616,6 +2601,61 @@ fn drawing_without_insunits_stays_unitless() {
assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into()));
}
#[test]
fn pdf_clips_a_primitive_beyond_the_coordinate_range() {
let mut d = Drawing::new_metric();
let paper = Space::Paper("Layout1".into());
let v = |x: f64, y: f64| Vec3::new(x, y, 0.0);
// 4e7 mm at 1:1 is about 1.1e8 pt, past the range the writer's f32 geometry takes.
d.add(&paper, Common::default(), EntityKind::Line(Line { a: v(10.0, 20.0), b: v(4.0e7, 20.0) })).unwrap();
d.add(&paper, Common::default(), EntityKind::Solid(Solid { corners: [v(10.0, 50.0), v(4.0e7, 50.0), v(10.0, 60.0), v(10.0, 60.0)] })).unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({"fit": false, "scale": 1.0, "compress": false})).unwrap();
let content = check_pdf(&bytes);
let clip = pdf_read::clip_rect(&content).unwrap();
// The x extent of what each paint operator paints.
let (mut xs, mut painted) = (Vec::new(), Vec::new());
for o in pdf_read::drawn(&content) {
match o.op.as_str() {
"m" | "l" => xs.push(o.nums[0]),
"S" | "f" => painted.push((o.op, xs.drain(..).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x))))),
_ => {}
}
}
let (stroke, fill) = (painted.iter().find(|p| p.0 == "S"), painted.iter().find(|p| p.0 == "f"));
// Both run from inside the plot area to its right edge: the stroke to just past it.
let (_, (lo, hi)) = stroke.expect("the line is drawn");
assert!(*lo < clip[2] && *hi > clip[2] && *hi < clip[2] + 5.0, "line from {lo} to {hi}, clip {clip:?}");
let (_, (lo, hi)) = fill.expect("the solid is filled");
assert!(*lo < clip[2] && (*hi - clip[2]).abs() < 1e-3, "fill from {lo} to {hi}, clip {clip:?}");
}
#[test]
fn pdf_plots_a_zero_length_line_as_a_dot() {
let mut d = Drawing::new_metric();
let red = |lw| Common { color: Color::Index(1), lineweight: lw, ..Common::default() };
// Points at 1:1 on A4, so the dot sits away from the clip edges.
let at = Vec3::new(50.0, 50.0, 0.0);
d.add(&Space::Model, red(Lineweight::Mm100(100)), EntityKind::Line(Line { a: at, b: at })).unwrap();
// The x extent of what the content fills in red: the dot's diameter.
let red_fill_width = |o: serde_json::Value| {
let content = check_pdf(&plot(&d, &Space::Model, &o).unwrap());
let ops = pdf_read::ops(&content);
let start = ops.iter().position(|o| o.op == "rg" && o.nums == [1.0, 0.0, 0.0]).expect("a red fill");
let end = start + ops[start..].iter().position(|o| o.op == "f").expect("filled");
assert!(ops[start..end].iter().filter(|o| o.op == "c").count() == 4, "{content}");
let xs: Vec<f64> =
ops[start..end].iter().filter(|o| matches!(o.op.as_str(), "m" | "c")).flat_map(|o| o.nums.iter().step_by(2).copied()).collect();
xs.iter().copied().fold(f64::MIN, f64::max) - xs.iter().copied().fold(f64::MAX, f64::min)
};
let opts = |lineweights: bool| serde_json::json!({"paper": "A4", "fit": false, "scale": 1.0, "compress": false, "lineweights": lineweights});
// 1 mm lineweight: a dot 1 mm across.
let w = red_fill_width(opts(true));
assert!((w - 72.0 / 25.4).abs() < 0.01, "{w}");
// A hairline still leaves a mark.
let w = red_fill_width(opts(false));
assert!((w - 0.25).abs() < 0.01, "{w}");
}
/// DXF text as (code, value) pairs.
fn dxf_pairs(text: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = text.lines().collect();
+202
View File
@@ -0,0 +1,202 @@
//! DIMTFILL (group 69) and DIMTFILLCLR (group 70) survive DXF: as DIMSTYLE groups from R2007,
//! after the record's own flags (also 70) and only when set, and in the style's `CADCRAFT`
//! "DIMVARS" xdata in older files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID.
use cadcraft_color::Color;
use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space};
use cadcraft_geom::Vec3;
use cadcraft_io::{SaveOptions, Version};
fn filled() -> DimStyle {
DimStyle { name: "Fill".into(), text_fill: 2, text_fill_color: Color::Index(3), ..DimStyle::default() }
}
fn drawing() -> Drawing {
let mut d = Drawing::new_imperial();
d.dim_styles.push(filled());
d
}
fn dxf(d: &Drawing, v: Version) -> String {
String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &SaveOptions { version: Some(v), binary: false }).unwrap()).unwrap()
}
/// The DIMSTYLE record named `name`, as trimmed lines.
fn record<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
let l: Vec<&str> = text.lines().map(str::trim).collect();
let at = l.windows(2).position(|w| w == ["2", name]).unwrap();
let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e);
l[at..end].to_vec()
}
#[test]
fn text_fill_roundtrips_in_the_dimstyle_table() {
let d = drawing();
for v in [Version::R2007, Version::R2018] {
let text = dxf(&d, v);
let rec = record(&text, "Fill");
let groups: Vec<(&str, &str)> = rec.chunks(2).filter_map(|p| Some((*p.first()?, *p.get(1)?))).collect();
assert!(groups.contains(&("69", "2")), "{v:?}: DIMTFILL as group 69");
let seventies: Vec<&str> = groups.iter().filter(|g| g.0 == "70").map(|g| g.1).collect();
assert_eq!(seventies, ["0", "3"], "{v:?}: the flags, then DIMTFILLCLR");
assert!(!rec.contains(&"DIMVARS"), "{v:?}: no xdata");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}");
}
for v in [Version::R2000, Version::R2004] {
let text = dxf(&d, v);
let rec = record(&text, "Fill");
assert!(!rec.chunks(2).any(|p| p[0] == "69"), "{v:?}: no group 69");
assert!(rec.windows(4).any(|w| w == ["1070", "69", "1070", "2"]), "{v:?}: DIMTFILL in the xdata");
assert!(rec.windows(4).any(|w| w == ["1070", "70", "1070", "3"]), "{v:?}: DIMTFILLCLR in the xdata");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}");
}
}
#[test]
fn unset_text_fill_writes_no_groups() {
let mut d = Drawing::new_imperial();
d.dim_styles.push(DimStyle { name: "Plain".into(), ..DimStyle::default() });
d.dim_styles.push(DimStyle { name: "Canvas".into(), text_fill: 1, ..DimStyle::default() });
let text = dxf(&d, Version::R2018);
let groups = |name| -> Vec<(String, String)> {
record(&text, name).chunks(2).filter_map(|p| Some((p.first()?.to_string(), p.get(1)?.to_string()))).collect()
};
let plain = groups("Plain");
// The record's flags are its only 70.
assert_eq!(plain.iter().filter(|g| g.0 == "70").count(), 1, "{plain:?}");
assert!(!plain.iter().any(|g| g.0 == "69"), "{plain:?}");
let canvas = groups("Canvas");
assert!(canvas.contains(&("69".into(), "1".into())), "{canvas:?}");
assert_eq!(canvas.iter().filter(|g| g.0 == "70").count(), 1, "the default DIMTFILLCLR is not written: {canvas:?}");
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Plain").unwrap().text_fill, 0);
assert_eq!(back.dim_style("Canvas").unwrap().text_fill, 1);
}
#[test]
fn hostile_text_fill_groups_fall_back_to_defaults() {
let text = dxf(&drawing(), Version::R2018);
for (fill, color) in [("99", "3"), ("-5", "3"), ("2", "40000"), ("2", "-7")] {
let edited = text.replacen(" 69\r\n2\r\n 70\r\n3\r\n", &format!(" 69\r\n{fill}\r\n 70\r\n{color}\r\n"), 1);
assert_ne!(edited, text, "groups replaced");
let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();
let st = back.dim_style("Fill").unwrap();
assert!(st.text_fill <= 2, "{fill}/{color}: {}", st.text_fill);
}
}
#[test]
fn record_flags_are_not_read_as_dimtfillclr() {
let text = dxf(&drawing(), Version::R2018);
// Flags 64 (referenced) and no DIMTFILLCLR group: the color stays ByBlock.
let edited =
text.replacen(" 2\r\nFill\r\n 70\r\n0\r\n", " 2\r\nFill\r\n 70\r\n64\r\n", 1).replacen(" 69\r\n2\r\n 70\r\n3\r\n", " 69\r\n2\r\n", 1);
assert!(edited.contains(" 2\r\nFill\r\n 70\r\n64\r\n"), "flags edited");
assert!(edited.contains(" 69\r\n2\r\n") && !edited.contains(" 69\r\n2\r\n 70\r\n3\r\n"), "DIMTFILLCLR removed");
let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();
let st = back.dim_style("Fill").unwrap();
assert_eq!((st.text_fill, st.text_fill_color), (2, Color::ByBlock));
}
#[test]
fn reopened_dimension_keeps_its_background_mask() {
let mut d = Drawing::new_imperial();
d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() });
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Dimension(dm) if dm.block.is_some())), "read with its block");
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
assert_eq!(list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Mask).count(), 1);
}
#[test]
fn reopened_color_fill_is_drawn_once() {
let mut d = drawing();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
// The block's SOLID in DIMTFILLCLR (color 3, green) is the background; no second one is added.
let green = list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Tris && p.color == cadcraft_color::Rgb(0, 255, 0)).count();
assert_eq!(green, 1);
}
#[test]
fn reopened_inspection_frame_stays_over_the_background() {
let mut d = Drawing::new_imperial();
d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() });
let mut dm = Dimension { style: "Fill".into(), ..linear() };
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: "A".into(), rate: String::new() });
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
let dm = back
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.clone()),
_ => None,
})
.unwrap();
assert!(dm.block.is_some(), "read with its block");
let corners = cadcraft_render::dimension_in(&back, &dm).text_fill;
let (lo, hi) = corners.iter().fold((corners[0], corners[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
let inside = |q: &cadcraft_geom::Vec2| q.x >= lo.x - 1e-6 && q.x <= hi.x + 1e-6 && q.y >= lo.y - 1e-6 && q.y <= hi.y + 1e-6;
let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default());
let mask = list.prims.iter().position(|p| p.kind == cadcraft_render::Kind::Mask).unwrap();
// The separator after the label, the frame's top and bottom: lines inside the background.
let marks: Vec<usize> = list
.prims
.iter()
.enumerate()
.filter(|(_, p)| p.kind == cadcraft_render::Kind::Polyline && list.points(p).len() == 2 && list.points(p).iter().all(inside))
.map(|(i, _)| i)
.collect();
assert!(marks.len() >= 3 && marks.iter().all(|i| *i > mask), "{marks:?} after the mask at {mask}");
}
/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5.
fn linear() -> Dimension {
Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(0.0, 5.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
#[test]
fn dimension_block_holds_the_text_background() {
let mut d = drawing();
// An inspection frame (top, bottom and two angular ends: six segments) goes over the background.
let mut dm = Dimension { style: "Fill".into(), ..linear() };
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() });
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
let name = back
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::Dimension(dm) => dm.block.clone(),
_ => None,
})
.unwrap();
let ents: Vec<_> = back.block(&name).unwrap().entities.iter().cloned().collect();
let solid = ents.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == Color::Index(3));
let mtext = ents.iter().position(|e| matches!(e.kind, EntityKind::MText(_)));
assert!(solid.is_some() && solid < mtext, "the background before the text: {solid:?}, {mtext:?}");
let lines_after = ents.iter().skip(solid.unwrap_or(0)).filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
assert_eq!(lines_after, 6, "the frame after the background, the other lines before it");
}
+2 -2
View File
@@ -44,9 +44,9 @@ fn a_millimetre_layout_plots_in_millimetres_whatever_measurement_says() {
// The frame lands 10 mm (28.35 pt) inside the page, not 10 inches.
let bytes = pdf(&d, &Space::Paper("SHEET".into()), &PdfOptions { compress: false, ..PdfOptions::default() }).unwrap();
let text = String::from_utf8_lossy(&bytes);
let doc = crate::pdf_read::read(&bytes);
let xs: Vec<f64> =
text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
assert!(!xs.is_empty(), "no path in the PDF");
let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
assert!((lo - 28.35).abs() < 0.1 && (hi - 813.54).abs() < 0.1, "frame from {lo} to {hi} pt");
+3
View File
@@ -13,6 +13,7 @@ mod dim_block_resave_roundtrip;
mod dim_block_text;
mod dim_marks_roundtrip;
mod dimstyle_vars_roundtrip;
mod dimtfill_roundtrip;
mod drafting_spacing;
#[cfg(not(target_arch = "wasm32"))]
mod dwg_header_vars;
@@ -45,6 +46,8 @@ mod paperview_marker;
mod passthrough_image_roundtrip;
mod pdf_followups;
mod pdf_layers;
#[path = "../support/pdf_read.rs"]
mod pdf_read;
mod plot_layout_scale;
mod plot_model_png_scale;
mod plot_styles;
+18 -16
View File
@@ -12,18 +12,22 @@ fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
d.add(&Space::Model, Common::default(), k).unwrap();
}
fn pdf_text(d: &Drawing, opts: Value) -> String {
fn pdf_doc(d: &Drawing, opts: Value) -> crate::pdf_read::Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
m.extend(e.clone());
}
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
crate::pdf_read::read(&plotter::plot(d, &Space::Model, &o).unwrap())
}
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
fn clip(pdf: &str) -> Vec<f64> {
let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or("");
line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect()
/// The plot area's clip rectangle (`x y w h`) of the first page.
fn clip(doc: &crate::pdf_read::Doc) -> Vec<f64> {
crate::pdf_read::clip_rect(doc.content()).map(|r| vec![r[0], r[1], r[2] - r[0], r[3] - r[1]]).unwrap_or_default()
}
/// The link targets of the first page.
fn uris(doc: &crate::pdf_read::Doc) -> Vec<String> {
doc.pages[0].links.iter().map(|l| l.uri.clone()).collect()
}
#[test]
@@ -32,17 +36,17 @@ fn model_space_pdf_uses_the_device_margins() {
line(&mut d, (0.0, 0.0), (100.0, 50.0));
let mm = 72.0 / 25.4;
// No device: the page setup's printable area, as before.
let plain = clip(&pdf_text(&d, json!({})));
let plain = clip(&pdf_doc(&d, json!({})));
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
// A device paper with 20 mm margins: the printable area starts 20 mm in.
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
let r = clip(&pdf_doc(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
assert_eq!(r.len(), 4, "{r:?}");
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
// Hostile margins are ignored.
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
assert_eq!(clip(&pdf_doc(&d, json!({"margins": bad}))), plain, "{bad}");
}
}
@@ -107,13 +111,11 @@ fn pe_url_hyperlinks_become_link_annotations() {
let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]);
assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links");
let read = read_dxf(text.as_bytes()).unwrap();
let pdf = pdf_text(&read, json!({}));
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}");
assert!(pdf.contains("https://example.com/plan"));
assert!(!pdf.contains("javascript") && !pdf.contains("data:text"));
let pdf = uris(&pdf_doc(&read, json!({})));
assert_eq!(pdf, ["https://example.com/plan"], "only the web link");
// Turned off, and given explicitly for the same object (the explicit one wins).
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
assert!(uris(&pdf_doc(&read, json!({"hyperlinks": false}))).is_empty());
let first = read.model.iter().next().unwrap().handle.hex();
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
let own = uris(&pdf_doc(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]})));
assert!(own.contains(&"https://example.org/own".to_string()) && !own.contains(&"https://example.com/plan".to_string()), "{own:?}");
}
+131 -118
View File
@@ -1,6 +1,6 @@
//! PDF export with layers, several sheets and hyperlinks (issue #560): drawing layers become
//! optional content groups, `layouts` plots several sheets into one file with page labels, and
//! linked entities get link annotations. The tests parse the written objects.
//! linked entities get link annotations. The tests read the written file back.
use cadcraft_color::Color;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Space, Viewport};
@@ -9,57 +9,19 @@ use cadcraft_io::pdf::{MAX_PAGES, pdf_sheets};
use cadcraft_io::{PdfOptions, plot};
use serde_json::json;
/// The objects of a PDF by number, after checking the cross-reference table points at them.
fn objects(bytes: &[u8]) -> Vec<(usize, String)> {
assert!(bytes.starts_with(b"%PDF-1."));
// Byte offsets stay valid: non-ASCII bytes (the binary comment) become `?`.
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let sx = text.rfind("startxref\n").expect("startxref");
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
assert!(text[xref..].starts_with("xref\n"));
let mut lines = text[xref..].lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let entries: Vec<&str> = lines.take(count).collect();
assert_eq!(entries.len(), count);
let mut out = Vec::new();
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
let head = format!("{i} 0 obj\n");
assert!(bytes[off..].starts_with(head.as_bytes()), "object {i} offset");
let body = &text[off + head.len()..];
let end = body.find("\nendobj\n").expect("endobj");
out.push((i, body[..end].to_string()));
}
assert!(text.contains(&format!("/Size {count}")));
out
use crate::pdf_read::{self, Doc};
fn read(bytes: &[u8]) -> Doc {
pdf_read::read(bytes)
}
fn object(objs: &[(usize, String)], n: usize) -> &str {
&objs.iter().find(|(i, _)| *i == n).unwrap().1
fn ocg_names(doc: &Doc) -> Vec<String> {
doc.ocgs.iter().map(|g| g.name.clone()).collect()
}
/// The object a `/Key n 0 R` entry of `dict` refers to.
fn reference(dict: &str, key: &str) -> usize {
let i = dict.find(&format!("{key} ")).unwrap_or_else(|| panic!("{key} in {dict}")) + key.len() + 1;
dict[i..].split_whitespace().next().unwrap().parse().unwrap()
}
/// Uncompressed content streams in page order.
fn contents(objs: &[(usize, String)]) -> Vec<String> {
pages(objs).iter().map(|p| object(objs, reference(p, "/Contents")).to_string()).collect()
}
fn pages(objs: &[(usize, String)]) -> Vec<String> {
objs.iter().filter(|(_, b)| b.starts_with("<< /Type /Page ")).map(|(_, b)| b.clone()).collect()
}
fn catalog(objs: &[(usize, String)]) -> String {
objs.iter().find(|(_, b)| b.starts_with("<< /Type /Catalog")).unwrap().1.clone()
}
/// The names of the optional content groups.
fn ocg_names(objs: &[(usize, String)]) -> Vec<String> {
objs.iter().filter_map(|(_, b)| b.strip_prefix("<< /Type /OCG /Name ").map(|r| r.trim_end_matches(" >>").to_string())).collect()
/// The `BDC` marks of a content: the property resource each one names.
fn marks(content: &str) -> Vec<String> {
pdf_read::ops(content).into_iter().filter(|o| o.op == "BDC").filter_map(|o| o.names.get(1).cloned()).collect()
}
fn add_line(d: &mut Drawing, space: &Space, layer: &str, a: (f64, f64), b: (f64, f64)) -> Handle {
@@ -109,45 +71,66 @@ fn raw(extra: serde_json::Value) -> serde_json::Value {
#[test]
fn drawing_layers_become_pdf_layers() {
let d = layered();
let objs = objects(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
// Plotted layers only, in drawing order of first use; block contents on layer 0 are on
// the block reference's layer.
assert_eq!(names, ["(0)", "(Walls)", "(Doors)"]);
let cat = catalog(&objs);
assert!(cat.contains("/Version /1.5") && cat.contains("/OCProperties") && cat.contains("/OFF []"), "{cat}");
// Every OCG is listed and switchable.
let ocgs: Vec<usize> = objs.iter().filter(|(_, b)| b.starts_with("<< /Type /OCG")).map(|(i, _)| *i).collect();
for id in &ocgs {
assert!(cat.contains(&format!("{id} 0 R")));
}
// the block reference's layer. None starts hidden.
assert_eq!(ocg_names(&doc), ["0", "Walls", "Doors"]);
assert!(doc.ocgs.iter().all(|g| !g.off));
// One sheet: the viewer opens with its layer panel.
assert_eq!(doc.page_mode.as_deref(), Some("UseOC"));
// The page maps its marks to the groups, and the marked sections are balanced.
// Marks are named after the layer's place in the layer table (0, Defpoints, Walls, …).
let page = &pages(&objs)[0];
for i in [0, 2, 5] {
let id = reference(page, &format!("/L{i}"));
assert!(ocgs.contains(&id));
let page = &doc.pages[0];
let groups: Vec<&str> = page.properties.iter().map(|(_, g)| g.as_str()).collect();
for n in ["0", "Walls", "Doors"] {
assert!(groups.contains(&n), "{n} in {groups:?}");
}
let c = &contents(&objs)[0];
assert_eq!(c.matches(" BDC\n").count(), c.matches("EMC\n").count());
assert!(c.contains("/OC /L2 BDC\n1 0 0 RG"), "the red wall line is on Walls: {c}");
let (q, bdc, emc) = (c.find("re W n").unwrap(), c.find("BDC").unwrap(), c.rfind("EMC").unwrap());
assert!(q < bdc && emc < c.rfind("Q\n").unwrap(), "marked content inside the clip's q/Q");
let c = &page.content;
assert_eq!(pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC"));
for m in marks(c) {
assert!(page.properties.iter().any(|(k, _)| *k == m), "mark {m} is a page property");
}
// The red wall line is on Walls.
let walls = page.properties.iter().find(|(_, g)| g == "Walls").map(|(k, _)| k.clone()).unwrap();
let ops = pdf_read::ops(c);
let at = ops.iter().position(|o| o.op == "BDC" && o.names.get(1) == Some(&walls)).unwrap();
let next = ops[at..].iter().find(|o| o.op == "RG").unwrap();
assert_eq!(next.nums, [1.0, 0.0, 0.0], "{c}");
// Marked content inside the clip's q/Q.
let first_bdc = ops.iter().position(|o| o.op == "BDC").unwrap();
let last_emc = ops.iter().rposition(|o| o.op == "EMC").unwrap();
let clip = ops.iter().position(|o| o.op == "W").unwrap();
let last_q = ops.iter().rposition(|o| o.op == "Q").unwrap();
assert!(clip < first_bdc && last_emc < last_q, "marked content inside the clip's q/Q");
}
#[test]
fn layers_that_are_off_are_included_hidden_on_request() {
let d = layered();
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
let names = ocg_names(&objs);
assert!(names.contains(&"(Notes)".to_string()) && !names.contains(&"(Hidden)".to_string()), "{names:?}");
let notes = objs.iter().find(|(_, b)| b.contains("/Name (Notes)")).unwrap().0;
let cat = catalog(&objs);
assert!(cat.contains(&format!("/OFF [{notes} 0 R]")), "{cat}");
// Off: neither the layer nor its objects; without layers, no optional content at all.
let flat = objects(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
assert!(ocg_names(&flat).is_empty() && !catalog(&flat).contains("/OCProperties"));
assert!(!contents(&flat)[0].contains("BDC"));
let doc = read(&plot(&d, &Space::Model, &raw(json!({"offLayers": true}))).unwrap());
let names = ocg_names(&doc);
assert!(names.contains(&"Notes".to_string()) && !names.contains(&"Hidden".to_string()), "{names:?}");
let off: Vec<&str> = doc.ocgs.iter().filter(|g| g.off).map(|g| g.name.as_str()).collect();
assert_eq!(off, ["Notes"]);
// Without layers, no optional content at all.
let flat = read(&plot(&d, &Space::Model, &raw(json!({"layers": false}))).unwrap());
assert!(flat.ocgs.is_empty() && flat.page_mode.is_none());
assert_eq!(pdf_read::count(flat.content(), "BDC"), 0);
}
#[test]
fn layers_that_are_off_are_plotted_only_on_request() {
let d = layered();
let strokes = |o: serde_json::Value| {
let doc = read(&plot(&d, &Space::Model, &raw(o)).unwrap());
pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m").count()
};
// By default Notes (off) and Hidden (frozen) aren't drawn: the four lines less those two,
// plus the door.
let plain = strokes(json!({}));
assert_eq!(plain, 3, "lines on 0 and Walls, and the door leaf");
// `offLayers` adds Notes (on a hidden PDF layer), never the frozen layer; it needs PDF layers.
assert_eq!(strokes(json!({"offLayers": true})), 4);
assert_eq!(strokes(json!({"layers": false, "offLayers": true})), plain);
}
#[test]
@@ -169,43 +152,50 @@ fn viewport_contents_keep_their_layers() {
};
d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));
let objs = objects(&plot(&d, &paper, &raw(json!({}))).unwrap());
let names = ocg_names(&objs);
let names = ocg_names(&read(&plot(&d, &paper, &raw(json!({}))).unwrap()));
// The viewport's frozen layer is left out; the model layers seen through it are kept.
for n in ["(Viewports)", "(0)", "(Doors)", "(Title)"] {
for n in ["Viewports", "0", "Doors", "Title"] {
assert!(names.contains(&n.to_string()), "{n} in {names:?}");
}
assert!(!names.contains(&"(Walls)".to_string()));
assert!(!names.contains(&"Walls".to_string()));
}
#[test]
fn several_sheets_in_one_file_with_page_labels() {
let mut d = layered();
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
let bytes = plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap();
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
// "all": model (it has objects), then every layout in tab order.
let all = objects(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
assert!(catalog(&all).contains("[0 << /P (Model) >> 1 << /P (Layout1) >> 2 << /P (Layout2) >>]"), "{}", catalog(&all));
let objs = objects(&bytes);
let tree = objs.iter().find(|(_, b)| b.starts_with("<< /Type /Pages")).unwrap().1.clone();
assert!(tree.contains("/Count 2"), "{tree}");
assert_eq!(pages(&objs).len(), 2);
let cat = catalog(&objs);
assert!(cat.contains("/PageLabels << /Nums [0 << /P (Model) >> 1 << /P (Layout1) >>] >>"), "{cat}");
// One OCG per layer for the whole file, shared by the pages.
let names = ocg_names(&objs);
assert_eq!(names.iter().filter(|n| *n == "(Walls)").count(), 1);
let walls = objs.iter().find(|(_, b)| b.contains("/Name (Walls)")).unwrap().0;
for p in pages(&objs) {
assert!(p.contains(&format!(" {walls} 0 R")), "{p}");
}
let all = read(&plot(&d, &Space::Model, &raw(json!({"layouts": "All"}))).unwrap());
assert_eq!(all.labels, ["Model", "Layout1", "Layout2"]);
assert_eq!(doc.pages.len(), 2);
assert_eq!(doc.labels, ["Model", "Layout1"]);
// Each page has its own sheet: model on A4 landscape, the layout on its page setup.
let c = contents(&objs);
assert!(c[0].contains("1 0 0 RG") && c[1].contains("1 0 0 RG"));
for p in &doc.pages {
assert!(pdf_read::ops(&p.content).iter().any(|o| o.op == "RG" && o.nums == [1.0, 0.0, 0.0]), "the red wall line: {}", p.content);
}
let size = |p: &pdf_read::Page| (p.media[2] - p.media[0], p.media[3] - p.media[1]);
assert!((size(&doc.pages[0]).0 - 841.89).abs() < 0.01 && (size(&doc.pages[0]).1 - 595.276).abs() < 0.01);
// The library call does the same.
let o = PdfOptions { compress: false, ..PdfOptions::default() };
let objs2 = objects(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert!(catalog(&objs2).contains("0 << /P (Layout1) >> 1 << /P (Model) >>"));
let doc2 = read(&pdf_sheets(&d, &[Space::Paper("Layout1".into()), Space::Model], &o).unwrap());
assert_eq!(doc2.labels, ["Layout1", "Model"]);
// Title: the first page's label unless given.
assert_eq!(doc2.title.as_deref(), Some("Layout1"));
assert_eq!((doc2.creator.as_deref(), doc2.producer.as_deref()), (Some("CADCraft"), Some("CADCraft")));
}
#[test]
fn several_sheets_share_one_pdf_layer_per_drawing_layer() {
let mut d = layered();
add_line(&mut d, &Space::Paper("Layout1".into()), "Walls", (10.0, 10.0), (50.0, 10.0));
let doc = read(&plot(&d, &Space::Model, &raw(json!({"layouts": ["model", "LAYOUT1"]}))).unwrap());
assert_eq!(ocg_names(&doc).iter().filter(|n| *n == "Walls").count(), 1);
for p in &doc.pages {
assert!(p.properties.iter().any(|(_, g)| g == "Walls"));
}
// Several sheets: no layer panel forced open.
assert!(doc.page_mode.is_none());
}
#[test]
@@ -230,28 +220,51 @@ fn hyperlinks_become_link_annotations() {
{"handle": h.hex(), "url": "https://example.com/ü"},
{"url": "https://example.com/no-handle"},
]);
let objs = objects(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
let annots: Vec<&String> = objs.iter().map(|(_, b)| b).filter(|b| b.contains("/Subtype /Link")).collect();
assert_eq!(annots.len(), 2, "{annots:?}");
assert!(annots[0].contains("/A << /S /URI /URI (https://example.com/a\\(1\\)) >>"), "{}", annots[0]);
assert!(annots[1].contains("(https://example.com/%C3%BC)"), "{}", annots[1]);
let page = &pages(&objs)[0];
assert!(page.contains("/Annots ["));
let doc = read(&plot(&d, &Space::Model, &raw(json!({ "links": links }))).unwrap());
let annots = &doc.pages[0].links;
assert_eq!(annots.len(), 2);
assert_eq!(annots[0].uri, "https://example.com/a(1)");
assert_eq!(annots[1].uri, "https://example.com/%C3%BC");
// The rectangle is inside the A4 landscape sheet and wide like the line.
let r = annots[0].split("/Rect [").nth(1).unwrap().split(']').next().unwrap();
let v: Vec<f64> = r.split_whitespace().map(|x| x.parse().unwrap()).collect();
let v = annots[0].rect;
assert!(v[0] >= 0.0 && v[2] <= 841.9 && v[1] >= 0.0 && v[3] <= 595.3 && v[2] - v[0] > 100.0, "{v:?}");
}
#[test]
fn unusual_layer_names_and_titles_are_escaped() {
fn unusual_names() -> Drawing {
let mut d = Drawing::new_metric();
for name in ["a(b)\\c", "Wände", "日本"] {
d.layers.push(Layer { name: name.into(), ..Layer::default() });
add_line(&mut d, &Space::Model, name, (0.0, 0.0), (10.0, 10.0));
}
let objs = objects(&plot(&d, &Space::Model, &raw(json!({"title": "Plan ü"}))).unwrap());
let names = ocg_names(&objs);
assert_eq!(names, ["(a\\(b\\)\\\\c)", "<FEFF005700E4006E00640065>", "<FEFF65E5672C>"]);
assert!(objs.iter().any(|(_, b)| b.contains("/Title <FEFF0050006C0061006E002000FC>")));
d
}
#[test]
fn unusual_layer_names_are_kept() {
let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({}))).unwrap());
assert_eq!(ocg_names(&doc), ["a(b)\\c", "Wände", "日本"]);
}
#[test]
fn unusual_titles_are_kept() {
let doc = read(&plot(&unusual_names(), &Space::Model, &raw(json!({"title": "Plan ü (1)\\"}))).unwrap());
assert_eq!(doc.title.as_deref(), Some("Plan ü (1)\\"));
}
#[test]
fn layers_past_the_cap_print_without_a_pdf_layer() {
// One more drawing layer than a file has PDF layers (4096).
let mut d = Drawing::new_metric();
for i in 0..4097 {
let name = format!("L{i}");
d.layers.push(Layer { name: name.clone(), ..Layer::default() });
add_line(&mut d, &Space::Model, &name, (0.0, f64::from(i)), (10.0, f64::from(i)));
}
let doc = read(&plot(&d, &Space::Model, &raw(json!({}))).unwrap());
assert_eq!(doc.ocgs.len(), 4096);
assert!(!ocg_names(&doc).contains(&"L4096".to_string()));
// Every line is still drawn; only the last is outside marked content.
let c = doc.content();
assert_eq!(pdf_read::drawn(c).iter().filter(|o| o.op == "m").count(), 4097);
assert_eq!((pdf_read::count(c, "BDC"), pdf_read::count(c, "EMC")), (4096, 4096));
}
+4 -3
View File
@@ -14,9 +14,10 @@ fn plotted_mm(opts: Value) -> f64 {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(&d, &Space::Paper("Layout1".into()), &o).unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
let xs: Vec<f64> = text.lines().filter(|l| l.ends_with(" m") || l.ends_with(" l")).filter_map(|l| l.split(' ').next()?.parse().ok()).collect();
assert_eq!(xs.len(), 2, "{text}");
let doc = crate::pdf_read::read(&bytes);
let xs: Vec<f64> =
crate::pdf_read::drawn(doc.content()).iter().filter(|o| o.op == "m" || o.op == "l").filter_map(|o| o.nums.first().copied()).collect();
assert_eq!(xs.len(), 2, "{}", doc.content());
(xs[1] - xs[0]).abs() * 25.4 / 72.0
}
+2 -2
View File
@@ -15,14 +15,14 @@ fn layout_with_red_line() -> Drawing {
d
}
/// The uncompressed PDF of Layout1 with extra plot options.
/// The page content of Layout1's PDF with extra plot options.
fn pdf_text(d: &Drawing, extra: serde_json::Value) -> String {
let mut opts = json!({"compress": false});
if let (Some(o), Some(e)) = (opts.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
let bytes = cadcraft_io::plot(d, &Space::Paper("Layout1".into()), &opts).unwrap();
String::from_utf8_lossy(&bytes).into_owned()
crate::pdf_read::read(&bytes).content().to_string()
}
#[test]
+91 -77
View File
@@ -10,6 +10,11 @@ use cadcraft_geom::{Vec2, Vec3};
use cadcraft_io::plot;
use serde_json::json;
#[path = "support/pdf_read.rs"]
mod pdf_read;
use pdf_read::Doc;
// ---------- a minimal TrueType font: .notdef, "A" (triangle), "B" (square) ----------
fn be16(v: &mut Vec<u8>, x: u16) {
@@ -46,8 +51,9 @@ fn simple_glyph(pts: &[(i16, i16)]) -> Vec<u8> {
g
}
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`.
fn test_font(fs_type: u16) -> Vec<u8> {
/// A TrueType font with glyphs .notdef (empty), A and B, and OS/2 `fsType`. `hmtx` holds
/// `metrics` of the three metrics `hhea` declares.
fn test_font(fs_type: u16, metrics: usize) -> Vec<u8> {
let glyphs = [Vec::new(), simple_glyph(&[(50, 0), (550, 0), (300, 700)]), simple_glyph(&[(50, 0), (550, 0), (550, 700), (50, 700)])];
let mut glyf = Vec::new();
let mut loca = Vec::new();
@@ -82,7 +88,7 @@ fn test_font(fs_type: u16) -> Vec<u8> {
be16(&mut maxp, v);
}
let mut hmtx = Vec::new();
for _ in 0..3 {
for _ in 0..metrics {
be16(&mut hmtx, 600);
be16(&mut hmtx, 50);
}
@@ -142,12 +148,14 @@ fn test_font(fs_type: u16) -> Vec<u8> {
out
}
/// Register the test fonts once: an embeddable one and one with a restricted licence.
/// Register the test fonts once: an embeddable one, one with a restricted licence, and one
/// the PDF writer accepts but can't subset (its `hmtx` is short).
fn fonts() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002));
cadcraft_fonts::ttf::register("cadcraft-pdf-test.ttf", test_font(0, 3));
cadcraft_fonts::ttf::register("cadcraft-pdf-restricted.ttf", test_font(0x0002, 3));
cadcraft_fonts::ttf::register("cadcraft-pdf-short-hmtx.ttf", test_font(0, 1));
});
}
@@ -178,77 +186,72 @@ fn drawing() -> Drawing {
let mut d = Drawing::new_metric();
style(&mut d, "Real", "cadcraft-pdf-test.ttf");
style(&mut d, "Locked", "cadcraft-pdf-restricted.ttf");
style(&mut d, "Short", "cadcraft-pdf-short-hmtx.ttf");
d
}
/// The uncompressed PDF as text (offsets kept: non-ASCII bytes become `?`).
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> String {
/// The uncompressed PDF, read back.
fn pdf(d: &Drawing, space: &Space, extra: serde_json::Value) -> Doc {
let mut o = json!({"compress": false});
if let (Some(m), Some(e)) = (o.as_object_mut(), extra.as_object()) {
m.extend(e.clone());
}
let bytes = plot(d, space, &o).unwrap();
check_xref(&bytes);
bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()
pdf_read::read(&plot(d, space, &o).unwrap())
}
/// The cross-reference table points at every object.
fn check_xref(bytes: &[u8]) {
let text: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let sx = text.rfind("startxref\n").unwrap();
let xref: usize = text[sx + 10..].lines().next().unwrap().trim().parse().unwrap();
let mut lines = text[xref..].lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
for (i, e) in lines.take(count).enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(text[off..].starts_with(&format!("{i} 0 obj\n")), "object {i}");
}
/// The glyph matrices (`a b c d e f`: em axes and origin on the page) of the text, in order.
fn matrices(doc: &Doc) -> Vec<[f64; 6]> {
pdf_read::text_matrices(doc.content())
}
/// The text matrices (`a b c d e f Tm`) of the content, in order.
fn matrices(pdf: &str) -> Vec<[f64; 6]> {
pdf.lines()
.filter(|l| l.ends_with(" Tj") && l.contains(" Tm "))
.map(|l| {
let v: Vec<f64> = l.split(" Tm ").next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
[v[0], v[1], v[2], v[3], v[4], v[5]]
})
/// The glyph codes shown, in order (two bytes each).
fn codes(doc: &Doc) -> Vec<u32> {
pdf_read::shown(&doc.pages[0].content_bytes)
.iter()
.flat_map(|s| s.chunks(2).map(|c| u32::from(c[0]) << 8 | u32::from(*c.get(1).unwrap_or(&0))).collect::<Vec<_>>())
.collect()
}
/// The embedded font file (FontFile2 stream data).
fn font_file(pdf: &str, bytes_of: &[u8]) -> Vec<u8> {
let i = pdf.find("/Length1 ").expect("a FontFile2 stream");
let head = &pdf[..i];
let len_at = head.rfind("/Length ").unwrap() + 8;
let len: usize = pdf[len_at..].split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = pdf[i..].find("stream\n").unwrap() + i + 7;
bytes_of[start..start + len].to_vec()
/// Filled paths drawn (glyph outlines are fills).
fn fills(doc: &Doc) -> usize {
pdf_read::count(doc.content(), "f")
}
/// Embedded font files.
fn font_files(doc: &Doc) -> usize {
doc.pages.iter().flat_map(|p| &p.fonts).filter(|f| f.font_file.is_some()).count()
}
#[test]
fn truetype_text_is_embedded_as_text() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
let doc = pdf(&d, &Space::Model, json!({}));
let page = &doc.pages[0];
// A Type0 font over a CIDFontType2 subset with a ToUnicode map.
assert!(p.contains("/Subtype /Type0") && p.contains("/Encoding /Identity-H") && p.contains("/Subtype /CIDFontType2"), "{p}");
assert!(p.contains("/CIDToGIDMap /Identity") && p.contains("/FontFile2 ") && p.contains("/ToUnicode "));
let base = p.split("/BaseFont /").nth(1).unwrap().split_whitespace().next().unwrap();
assert!(base.len() == 11 && base.as_bytes()[6] == b'+' && base[..6].chars().all(|c| c.is_ascii_uppercase()), "{base}");
assert!(p.contains("<0001> <0041>") && p.contains("<0002> <0042>"), "ToUnicode maps glyphs to A and B");
assert!(p.contains("/W [1 [600] 2 [600]]"), "widths");
// The page uses the font; four glyphs written as text, in a text object.
assert!(p.contains("/Font << /F0 "));
let tj: Vec<&str> = p.lines().filter(|l| l.ends_with(" Tj")).map(|l| l.rsplit(' ').nth(1).unwrap()).collect();
assert_eq!(tj, ["<0001>", "<0002>", "<0002>", "<0001>"]);
assert!(p.contains("BT\n/F0 1 Tf\n") && p.contains("ET\n"));
assert_eq!(p.matches("BT\n").count(), p.matches("ET\n").count());
assert_eq!(page.fonts.len(), 1);
let f = &page.fonts[0];
assert_eq!((f.subtype.as_str(), f.encoding.as_deref(), f.descendant.as_deref()), ("Type0", Some("Identity-H"), Some("CIDFontType2")));
assert!(f.cid_to_gid.as_deref() == Some("Identity") && f.font_file.is_some() && f.to_unicode.is_some());
let base = f.base_font.as_bytes();
assert!(base.len() > 7 && base[6] == b'+' && base[..6].iter().all(u8::is_ascii_uppercase), "{}", f.base_font);
// Four glyphs written as text: A B B A.
let c = codes(&doc);
assert_eq!(c.len(), 4, "{c:?}");
assert!(c[0] == c[3] && c[1] == c[2] && c[0] != c[1], "{c:?}");
let map = pdf_read::to_unicode_map(f.to_unicode.as_deref().unwrap());
assert!(map.contains(&(c[0], "A".into())) && map.contains(&(c[1], "B".into())), "ToUnicode maps the glyphs to A and B: {map:?}");
for code in [c[0], c[1]] {
assert!(f.widths.contains(&(code, 600.0)), "width of {code}: {:?}", f.widths);
}
// Text objects are balanced, at font size 1.
assert_eq!(pdf_read::count(&page.content, "BT"), pdf_read::count(&page.content, "ET"));
assert!(pdf_read::ops(&page.content).iter().filter(|o| o.op == "Tf").all(|o| o.nums == [1.0]));
// No filled outlines left for them.
assert!(!p.contains("h\nf\n"), "glyph outlines are not drawn: {p}");
assert_eq!(fills(&doc), 0, "glyph outlines are not drawn: {}", page.content);
// The glyphs advance along the baseline by 600/1000 em, scaled like the outline would be.
let m = matrices(&p);
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0));
let m = matrices(&doc);
assert!(m.iter().all(|m| m[1].abs() < 1e-3 && m[2].abs() < 1e-3 && (m[0] - m[3]).abs() < 1e-3 && m[0] > 0.0), "{m:?}");
let step = m[1][4] - m[0][4];
assert!((step - 0.6 * m[0][0]).abs() < 0.01, "advance {step} for em {}", m[0][0]);
assert!(m.windows(2).all(|w| (w[0][5] - w[1][5]).abs() < 1e-3), "one baseline");
@@ -258,21 +261,12 @@ fn truetype_text_is_embedded_as_text() {
fn the_subset_is_a_valid_truetype_font() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0);
let bytes = plot(&d, &Space::Model, &json!({"compress": false})).unwrap();
let p: String = bytes.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect();
let file = font_file(&p, &bytes);
assert!(p.contains(&format!("/Length1 {}", file.len())));
let doc = pdf(&d, &Space::Model, json!({}));
let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file");
let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses");
assert_eq!(cadcraft_fonts::subset::embedding(&face), cadcraft_fonts::subset::Embedding::Allowed);
// Glyph A kept, B (unused) emptied: the subset is smaller than the font.
assert!(file.len() < test_font(0).len());
// Whole-file checksum adjusted.
let sum = file.chunks(4).fold(0u32, |s, c| {
let mut w = [0u8; 4];
w[..c.len()].copy_from_slice(c);
s.wrapping_add(u32::from_be_bytes(w))
});
assert_eq!(sum, 0xB1B0_AFBA);
// Glyph A kept, B (unused) left out: the subset is smaller than the font.
assert!(file.len() < test_font(0, 3).len());
}
#[test]
@@ -283,7 +277,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0);
text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians());
let m = matrices(&pdf(&d, &Space::Model, json!({})));
assert_eq!(m.len(), 3);
assert_eq!(m.len(), 3, "{m:?}");
// Rotated a quarter turn: the baseline points up, "up" points left.
let [a, b, c, dd, _, _] = m[0];
assert!(a.abs() < 1e-3 && b > 0.0 && c < 0.0 && dd.abs() < 1e-3, "{:?}", m[0]);
@@ -297,21 +291,29 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() {
assert!(em_mm > 0.0);
}
#[test]
fn a_font_the_writer_cannot_subset_plots_as_outlines() {
let mut d = drawing();
text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content());
}
#[test]
fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() {
let mut d = drawing();
text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "restricted licence: outlines");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines");
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({"text": "geometry"}));
assert!(!p.contains("/FontFile2") && !p.contains("BT\n") && p.contains("h\nf\n"), "text as geometry");
assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry");
// The stroke font is always geometry.
let mut d = drawing();
text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
assert!(!p.contains("BT\n") && p.contains(" S\n") || p.contains("\nS\n"));
assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0);
}
#[test]
@@ -337,14 +339,26 @@ fn text_seen_through_a_viewport_and_on_layers() {
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0);
let p = pdf(&d, &paper, json!({}));
let tj = p.lines().filter(|l| l.ends_with(" Tj")).count();
// "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The
// glyph the edge cuts stays a (clipped) outline.
assert_eq!(tj, 4, "{p}");
assert!(p.contains("h\nf\n"), "the cut glyph is an outline");
// Text sits in its layer's marked content.
let first_bt = p.find("BT\n").unwrap();
assert!(p[..first_bt].contains("/OC /L0 BDC"));
assert_eq!(pdf_read::count(p.content(), "Tj"), 4, "{}", p.content());
assert!(fills(&p) > 0, "the cut glyph is an outline");
// Text seen through the viewport sits in its layer's marked content.
let ops = pdf_read::ops(p.content());
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
}
#[test]
fn text_sits_in_its_layers_marked_content() {
let mut d = drawing();
text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0);
let p = pdf(&d, &Space::Model, json!({}));
let ops = pdf_read::ops(p.content());
let first_bt = ops.iter().position(|o| o.op == "BT").unwrap();
let layer0 = p.pages[0].properties.iter().find(|(_, g)| g == "0").map(|(k, _)| k.clone()).unwrap();
assert!(ops[..first_bt].iter().any(|o| o.op == "BDC" && o.names.get(1) == Some(&layer0)));
}
#[test]
+518
View File
@@ -0,0 +1,518 @@
//! Reads a plotted PDF back for tests, with craft-pdf's reader: pages, media boxes, decoded
//! content, transparency graphics states, link annotations, fonts, page labels and document
//! information.
#![allow(dead_code)]
use std::ops::Deref;
use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfString};
/// What a reader sees in a PDF.
pub struct Doc {
pub pages: Vec<Page>,
/// Page labels, one per page (empty when the file has none).
pub labels: Vec<String>,
pub title: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
/// Optional content groups (PDF layers) in `/OCProperties` order.
pub ocgs: Vec<Ocg>,
/// The catalog's `/PageMode`.
pub page_mode: Option<String>,
}
pub struct Ocg {
pub name: String,
/// Listed in the default configuration's `/OFF` array (starts hidden).
pub off: bool,
}
pub struct Page {
/// `[x0 y0 x1 y1]` in points.
pub media: [f64; 4],
/// The decoded content stream(s), as text (binary string bytes replaced).
pub content: String,
/// The decoded content stream(s) as bytes.
pub content_bytes: Vec<u8>,
/// Whether a content stream is compressed (has a filter).
pub compressed: bool,
/// Stroke and fill alpha (`/CA`, `/ca`) of each graphics state in the page's resources.
pub alphas: Vec<(f64, f64)>,
pub links: Vec<Link>,
pub fonts: Vec<Font>,
/// The page's marked-content properties: resource name and the name of the optional
/// content group it refers to.
pub properties: Vec<(String, String)>,
}
pub struct Link {
/// `[x0 y0 x1 y1]` in points.
pub rect: [f64; 4],
pub uri: String,
}
pub struct Font {
pub subtype: String,
pub base_font: String,
pub encoding: Option<String>,
/// The descendant font's subtype (Type0 fonts).
pub descendant: Option<String>,
/// The descendant font's `/CIDToGIDMap` name.
pub cid_to_gid: Option<String>,
/// Glyph widths (`/W` of the descendant font): code and width.
pub widths: Vec<(u32, f64)>,
/// The decoded `ToUnicode` CMap.
pub to_unicode: Option<String>,
/// The embedded TrueType file (`FontFile2`).
pub font_file: Option<Vec<u8>>,
}
/// A PDF text string: UTF-16BE with a byte order mark, else one byte per character.
pub fn text_string(b: &[u8]) -> String {
match b.strip_prefix(&[0xFE, 0xFF]) {
Some(u) => String::from_utf16_lossy(&u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect::<Vec<_>>()),
None => b.iter().map(|c| char::from(*c)).collect(),
}
}
fn name(d: &Dict<'_>, key: &[u8]) -> Option<String> {
d.get::<Name<'_>>(key).map(|n| n.as_str().to_string())
}
fn rect(a: &Array<'_>) -> [f64; 4] {
let v: Vec<f64> = a.iter::<f64>().collect();
assert_eq!(v.len(), 4, "a rectangle: {v:?}");
[v[0], v[1], v[2], v[3]]
}
fn decoded(s: &Stream<'_>) -> Vec<u8> {
s.decoded().expect("a stream that decodes").to_vec()
}
fn font(d: &Dict<'_>) -> Font {
let descendant = d.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next());
let font_file =
descendant.as_ref().unwrap_or(d).get::<Dict<'_>>(b"FontDescriptor").and_then(|fd| fd.get::<Stream<'_>>(b"FontFile2")).map(|s| decoded(&s));
let mut widths = Vec::new();
if let Some(w) = descendant.as_ref().and_then(|c| c.get::<Array<'_>>(b"W")) {
let mut it = w.flex_iter();
while let Some(first) = it.next::<u32>() {
if let Some(list) = it.next::<Array<'_>>() {
widths.extend(list.iter::<f64>().enumerate().map(|(i, v)| (first + i as u32, v)));
} else {
let (last, v) = (it.next::<u32>().expect("a /W range end"), it.next::<f64>().expect("a /W range width"));
widths.extend((first..=last).map(|c| (c, v)));
}
}
}
Font {
encoding: name(d, b"Encoding"),
cid_to_gid: descendant.as_ref().and_then(|c| name(c, b"CIDToGIDMap")),
widths,
subtype: name(d, b"Subtype").unwrap_or_default(),
base_font: name(d, b"BaseFont").unwrap_or_default(),
descendant: descendant.as_ref().and_then(|c| name(c, b"Subtype")),
to_unicode: d.get::<Stream<'_>>(b"ToUnicode").map(|s| String::from_utf8_lossy(&decoded(&s)).into_owned()),
font_file,
}
}
/// Read a PDF; panics when it doesn't parse.
pub fn read(bytes: &[u8]) -> Doc {
assert!(bytes.starts_with(b"%PDF-"), "a PDF header");
let pdf = craft_pdf::open_slice(bytes, None).expect("the PDF opens");
let mut pages = Vec::new();
for page in pdf.pages().iter() {
let m = page.media_box();
let raw = page.raw();
let resources = raw.get::<Dict<'_>>(b"Resources");
let dicts = |key: &[u8]| -> Vec<Dict<'_>> {
let Some(d) = resources.as_ref().and_then(|r| r.get::<Dict<'_>>(key)) else { return Vec::new() };
let keys: Vec<Vec<u8>> = d.keys().map(|k| k.deref().to_vec()).collect();
keys.iter().filter_map(|k| d.get::<Dict<'_>>(k.as_slice())).collect()
};
let alphas = dicts(b"ExtGState").iter().map(|g| (g.get::<f64>(b"CA").unwrap_or(1.0), g.get::<f64>(b"ca").unwrap_or(1.0))).collect();
let fonts = dicts(b"Font").iter().map(font).collect();
let properties = match resources.as_ref().and_then(|r| r.get::<Dict<'_>>(b"Properties")) {
Some(d) => d
.keys()
.filter_map(|k| {
let ocg = d.get::<Dict<'_>>(k.deref())?;
Some((k.as_str().to_string(), ocg.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default()))
})
.collect(),
None => Vec::new(),
};
let links = raw
.get::<Array<'_>>(b"Annots")
.map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>())
.unwrap_or_default()
.iter()
.filter(|a| name(a, b"Subtype").as_deref() == Some("Link"))
.map(|a| Link {
rect: rect(&a.get::<Array<'_>>(b"Rect").expect("a link rectangle")),
uri: a
.get::<Dict<'_>>(b"A")
.and_then(|act| act.get::<PdfString<'_>>(b"URI"))
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
.unwrap_or_default(),
})
.collect();
let bytes = page.page_stream().expect("a decodable page content stream");
// The writer flips its y-down surface back, so the plot area is drawn in PDF points.
let content = String::from_utf8_lossy(bytes).into_owned();
if let Some(ctm) = clip_ctm(&content) {
assert!(ctm.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6), "the plot area is drawn under {ctm:?}, not in page space");
}
pages.push(Page {
media: [m.x0, m.y0, m.x1, m.y1],
content,
content_bytes: bytes.to_vec(),
compressed: raw
.get::<Stream<'_>>(b"Contents")
.map(|c| vec![c])
.or_else(|| raw.get::<Array<'_>>(b"Contents").map(|a| a.iter::<Stream<'_>>().collect()))
.unwrap_or_default()
.iter()
.any(|c| c.dict().contains_key(b"Filter")),
alphas,
links,
fonts,
properties,
});
}
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).expect("a catalog");
let mut starts: Vec<(usize, String)> = Vec::new();
if let Some(nums) = catalog.get::<Dict<'_>>(b"PageLabels").and_then(|l| l.get::<Array<'_>>(b"Nums")) {
let mut it = nums.flex_iter();
while let Some(i) = it.next::<i64>() {
let d = it.next::<Dict<'_>>().expect("a page label dictionary");
starts.push((i as usize, d.get::<PdfString<'_>>(b"P").map(|s| text_string(s.as_bytes())).unwrap_or_default()));
}
}
let labels = if starts.is_empty() {
Vec::new()
} else {
(0..pages.len()).map(|i| starts.iter().rev().find(|(s, _)| *s <= i).map(|(_, p)| p.clone()).unwrap_or_default()).collect()
};
let mut ocgs = Vec::new();
if let Some(oc) = catalog.get::<Dict<'_>>(b"OCProperties") {
let off: Vec<_> = oc
.get::<Dict<'_>>(b"D")
.and_then(|d| d.get::<Array<'_>>(b"OFF"))
.map(|a| a.iter::<Dict<'_>>().filter_map(|g| g.obj_id()).collect())
.unwrap_or_default();
for g in oc.get::<Array<'_>>(b"OCGs").map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>()).unwrap_or_default() {
ocgs.push(Ocg {
name: g.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default(),
off: g.obj_id().is_some_and(|id| off.contains(&id)),
});
}
}
let page_mode = name(&catalog, b"PageMode");
let md = pdf.metadata();
let s = |v: &Option<Vec<u8>>| v.as_deref().map(text_string);
Doc { labels, title: s(&md.title), creator: s(&md.creator), producer: s(&md.producer), pages, ocgs, page_mode }
}
impl Doc {
/// The first page's content.
pub fn content(&self) -> &str {
&self.pages.first().expect("a page").content
}
pub fn media(&self) -> (f64, f64) {
let m = self.pages.first().expect("a page").media;
(m[2] - m[0], m[3] - m[1])
}
}
/// One content operator with its numeric operands.
#[derive(Debug, Clone, PartialEq)]
pub struct Op {
pub op: String,
pub nums: Vec<f64>,
/// Name operands, without the slash.
pub names: Vec<String>,
}
/// The operators of content text in order, each with its numeric operands (other operands
/// are skipped).
pub fn ops(content: &str) -> Vec<Op> {
let b: Vec<char> = content.chars().collect();
let mut out = Vec::new();
let mut nums = Vec::new();
let mut names = Vec::new();
let mut i = 0;
let delim = |c: char| c.is_whitespace() || "()<>[]{}/%".contains(c);
while i < b.len() {
let c = b[i];
if c.is_whitespace() || c == '[' || c == ']' {
i += 1;
} else if c == '%' {
while i < b.len() && b[i] != '\n' {
i += 1;
}
} else if c == '(' {
let mut depth = 0;
while i < b.len() {
match b[i] {
'\\' => i += 1,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
i += 1;
} else if c == '<' && b.get(i + 1) == Some(&'<') {
// An inline dictionary (marked-content properties): skip to its end.
let mut depth = 0;
while i < b.len() {
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
depth += 1;
i += 1;
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
depth -= 1;
i += 1;
if depth == 0 {
i += 1;
break;
}
}
i += 1;
}
} else if c == '<' {
while i < b.len() && b[i] != '>' {
i += 1;
}
i += 1;
} else if c == '/' {
i += 1;
let start = i;
while i < b.len() && !delim(b[i]) {
i += 1;
}
names.push(b[start..i].iter().collect());
} else {
let start = i;
while i < b.len() && !delim(b[i]) {
i += 1;
}
let word: String = b[start..i.max(start + 1)].iter().collect();
i = i.max(start + 1);
match word.parse::<f64>() {
Ok(v) => nums.push(v),
Err(_) => out.push(Op { op: word, nums: std::mem::take(&mut nums), names: std::mem::take(&mut names) }),
}
}
}
out
}
/// How many times operator `op` occurs.
pub fn count(content: &str, op: &str) -> usize {
ops(content).iter().filter(|o| o.op == op).count()
}
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
/// Apply matrix `m` to the point `(x, y)`.
fn apply(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
/// The operators of content text with path coordinates (`m`, `l`, `c`, `v`, `y`, `re`) mapped
/// through the current transformation matrix, so they are in page space. A `re` becomes the
/// rectangle between its mapped corners, which holds for the axis-aligned matrices plots use.
pub fn page_ops(content: &str) -> Vec<Op> {
let mut stack = vec![IDENTITY];
let mut out = ops(content);
for o in &mut out {
let ctm = *stack.last().expect("a graphics state");
match o.op.as_str() {
"q" => stack.push(ctm),
"Q" => {
stack.pop();
assert!(!stack.is_empty(), "unbalanced Q");
}
"cm" if o.nums.len() == 6 => {
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
}
"m" | "l" | "c" | "v" | "y" => {
for [x, y] in o.nums.as_chunks_mut::<2>().0 {
(*x, *y) = apply(ctm, *x, *y);
}
}
"re" if o.nums.len() == 4 => {
let (x0, y0) = apply(ctm, o.nums[0], o.nums[1]);
let (x1, y1) = apply(ctm, o.nums[0] + o.nums[2], o.nums[1] + o.nums[3]);
o.nums = vec![x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()];
}
_ => {}
}
}
out
}
/// The current transformation matrix at the first clip (`W`): what the plot area is drawn under.
pub fn clip_ctm(content: &str) -> Option<[f64; 6]> {
let mut stack = vec![IDENTITY];
for o in ops(content) {
let ctm = *stack.last().expect("a graphics state");
match o.op.as_str() {
"W" => return Some(ctm),
"q" => stack.push(ctm),
"Q" => {
stack.pop();
}
"cm" if o.nums.len() == 6 => {
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
}
_ => {}
}
}
None
}
/// The clip rectangle in page space: the bounds of the path closed by the first `W n`.
pub fn clip_rect(content: &str) -> Option<[f64; 4]> {
let all = page_ops(content);
let end = all.iter().position(|o| o.op == "W")?;
let start = all[..end].iter().rposition(|o| !matches!(o.op.as_str(), "m" | "l" | "h" | "re")).map_or(0, |i| i + 1);
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for o in &all[start..end] {
let pts: Vec<(f64, f64)> = match o.op.as_str() {
"re" => vec![(o.nums[0], o.nums[1]), (o.nums[0] + o.nums[2], o.nums[1] + o.nums[3])],
"m" | "l" => vec![(o.nums[0], o.nums[1])],
_ => vec![],
};
for (x, y) in pts {
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
}
}
Some(b)
}
/// The operators after the clip (the marks drawn inside the plot area), with path coordinates
/// in page space.
pub fn drawn(content: &str) -> Vec<Op> {
let all = page_ops(content);
let after = all.iter().position(|o| o.op == "W").map_or(0, |i| i + 1);
all.into_iter().skip(after).collect()
}
/// `a × b` for PDF matrices `[a b c d e f]` (apply `a`, then `b`).
pub fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
[
a[0] * b[0] + a[1] * b[2],
a[0] * b[1] + a[1] * b[3],
a[2] * b[0] + a[3] * b[2],
a[2] * b[1] + a[3] * b[3],
a[4] * b[0] + a[5] * b[2] + b[4],
a[4] * b[1] + a[5] * b[3] + b[5],
]
}
/// The glyph-space-to-page matrix of each text-showing operator (`Tj`/`TJ`), in order: the text
/// matrix scaled by the font size, under the current transformation matrix.
pub fn text_matrices(content: &str) -> Vec<[f64; 6]> {
let mut stack = vec![IDENTITY];
let (mut tm, mut size) = (IDENTITY, 1.0);
let mut out = Vec::new();
for o in ops(content) {
let ctm = *stack.last().expect("a graphics state");
match o.op.as_str() {
"q" => stack.push(ctm),
"Q" => {
stack.pop();
}
"cm" if o.nums.len() == 6 => {
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
}
"BT" => tm = IDENTITY,
"Tm" if o.nums.len() == 6 => tm = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]],
"Tf" => size = o.nums.first().copied().unwrap_or(1.0),
"Tj" | "TJ" => out.push(mul(mul([size, 0.0, 0.0, size, 0.0, 0.0], tm), ctm)),
_ => {}
}
}
out
}
/// The string operands of text-showing operators (`Tj`), as bytes, in order.
pub fn shown(content: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut i = 0;
while i < content.len() {
if content[i] == b'(' {
let mut s = Vec::new();
let mut depth = 1;
i += 1;
while i < content.len() {
match content[i] {
b'\\' => {
i += 1;
let c = content.get(i).copied().unwrap_or(b'\\');
if c.is_ascii_digit() {
let digits: Vec<u8> = content[i..].iter().take(3).take_while(|d| (b'0'..=b'7').contains(d)).copied().collect();
s.push(u8::from_str_radix(std::str::from_utf8(&digits).unwrap_or("0"), 8).unwrap_or(0));
i += digits.len() - 1;
} else {
s.push(match c {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
other => other,
});
}
}
b'(' => {
depth += 1;
s.push(b'(');
}
b')' => {
depth -= 1;
if depth == 0 {
break;
}
s.push(b')');
}
c => s.push(c),
}
i += 1;
}
let rest = &content[(i + 1).min(content.len())..];
let next: Vec<u8> = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take(2).copied().collect();
if next == b"Tj" {
out.push(s);
}
}
i += 1;
}
out
}
/// The `bfchar` entries of a `ToUnicode` CMap: code and text.
pub fn to_unicode_map(cmap: &str) -> Vec<(u32, String)> {
let mut out = Vec::new();
for block in cmap.split("beginbfchar").skip(1) {
let body = block.split("endbfchar").next().unwrap_or("");
let hex: Vec<&str> = body.split(['<', '>']).map(str::trim).filter(|t| !t.is_empty()).collect();
for pair in hex.chunks(2) {
let [code, text] = pair else { continue };
let units: Vec<u16> = (0..text.len() / 4).filter_map(|i| u16::from_str_radix(&text[i * 4..i * 4 + 4], 16).ok()).collect();
if let Ok(c) = u32::from_str_radix(code, 16) {
out.push((c, String::from_utf16_lossy(&units)));
}
}
}
out
}
+32 -9
View File
@@ -4,7 +4,7 @@
//! fixed-length extension lines (DIMFXL/DIMFXLON), text placement (DIMTAD, DIMTVP, DIMJUST,
//! DIMTIH/DIMTOH, DIMGAP, DIMTMOVE) and text formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND,
//! DIMPOST, DIMTOL/DIMLIM with DIMTZIN and DIMTOLJ, DIMALT with DIMALTU/DIMALTZ/DIMALTRND,
//! DIMAZIN, DIMARCSYM).
//! DIMAZIN, DIMARCSYM) with its background (DIMTFILL).
use cadcraft_doc::{DimKind, DimStyle, Dimension};
use cadcraft_fonts::{MTextParams, TextFont};
@@ -32,6 +32,12 @@ pub struct DimGeometry {
pub line_roles: Vec<LineRole>,
/// Filled triangles (three points each) in the dimension line colour.
pub fills: Vec<Vec<Vec2>>,
/// DIMTFILL 1 or 2: the corners of the text's background, the text box widened by DIMGAP
/// on every side (empty without a fill or text).
pub text_fill: Vec<Vec2>,
/// Indices into `lines` of the marks drawn with the text, over `text_fill`: the arc length
/// symbol above the text (DIMARCSYM 1) and the DIMINSPECT frame.
pub text_marks: Vec<usize>,
/// Text strokes (stroke font, fraction bars, underlines).
pub text: Vec<Vec<Vec2>>,
/// Text glyph outlines (TrueType), grouped per glyph.
@@ -75,6 +81,13 @@ impl DimGeometry {
self.line_roles.push(LineRole::Dim);
}
}
/// A dimension line that marks the text (see `text_marks`).
fn text_mark(&mut self, l: Vec<Vec2>) {
if l.len() >= 2 {
self.text_marks.push(self.lines.len());
self.dim(l);
}
}
fn ext(&mut self, l: Vec<Vec2>) {
if l.len() >= 2 {
self.lines.push(l);
@@ -88,18 +101,21 @@ impl DimGeometry {
}
let mut lines = Vec::with_capacity(self.lines.len());
let mut roles = Vec::with_capacity(self.lines.len());
let mut ext2 = Vec::new();
let (mut ext2, mut marks) = (Vec::new(), Vec::new());
for (i, (l, rl)) in self.lines.iter().zip(&self.line_roles).enumerate() {
let pieces = if role.is_none_or(|x| x == *rl) { cut_polyline(l, c, r) } else { vec![l.clone()] };
for p in pieces {
if self.ext2_lines.contains(&i) {
ext2.push(lines.len());
}
if self.text_marks.contains(&i) {
marks.push(lines.len());
}
lines.push(p);
roles.push(*rl);
}
}
(self.lines, self.line_roles, self.ext2_lines) = (lines, roles, ext2);
(self.lines, self.line_roles, self.ext2_lines, self.text_marks) = (lines, roles, ext2, marks);
}
/// Extension line 1 or 2 (`second`).
fn ext_n(&mut self, l: Option<Vec<Vec2>>, second: bool) {
@@ -987,7 +1003,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35)
})
.collect();
g.dim(sym);
g.text_mark(sym);
}
}
}
@@ -1017,24 +1033,31 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
let mut left = -w / 2.0;
if !ins.label.is_empty() {
left += wl;
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
left += gap2;
}
if !ins.rate.is_empty() {
left += wv;
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
}
g.dim(vec![at(-hw, hh), at(hw, hh)]);
g.dim(vec![at(-hw, -hh), at(hw, -hh)]);
g.text_mark(vec![at(-hw, hh), at(hw, hh)]);
g.text_mark(vec![at(-hw, -hh), at(hw, -hh)]);
for side in [-1.0, 1.0] {
let ends: Vec<Vec2> = if ins.shape == 0 {
(0..=12).map(|i| f64::from(i) * std::f64::consts::PI / 12.0).map(|a| at(side * (hw + hh * a.sin()), hh * a.cos())).collect()
} else {
vec![at(side * hw, hh), at(side * (hw + hh), 0.0), at(side * hw, -hh)]
};
g.dim(ends);
g.text_mark(ends);
}
}
// DIMTFILL: a background behind the text, the text box plus DIMGAP on every side.
if st.text_fill != 0 && !g.mtext.is_empty() {
let (w, h) = measure(&g.mtext);
let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp());
let (hw, hh) = (w / 2.0 + gap, h / 2.0 + gap);
g.text_fill = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().map(|(px, py)| text_mid + x * px + y * py).collect();
}
g.value = decode_value(&cadcraft_fonts::plain_mtext(&g.mtext));
if !g.mtext.is_empty() {
let l = cadcraft_fonts::layout_mtext_with(&g.mtext, &params(text_mid, text_angle));
+138 -6
View File
@@ -1125,10 +1125,39 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
&& ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH
{
let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) };
// DIMTFILL: the block's MTEXT carries no background mask here, so a background
// around each of the block's texts is drawn before the block's first text or first
// line of the text's marks, except for a text that a SOLID of the block covers.
let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() };
let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0));
let boxes: Vec<Vec<Vec2>> = if g.text_fill.is_empty() {
Vec::new()
} else {
let solids: Vec<&Entity> =
blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect();
// A text's own box (no gap): a SOLID holding all its corners is its background.
let covered =
|be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p))));
blk.entities
.iter()
.filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_)))
.take(MAX_FILL_TEXTS)
.filter(|be| !covered(be))
.filter_map(|be| block_text_box(ctx.d, be, gap))
.collect()
};
let mut fill_pending = !boxes.is_empty();
for be in blk.entities.iter() {
if !b.expand() {
break;
}
if fill_pending && starts_text_marks(be, &g) {
for corners in &boxes {
dim_text_fill(b, ctx, e, &style, corners);
}
fill_pending = false;
}
entity(b, &sub, be);
}
} else {
@@ -1455,23 +1484,126 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio
};
let (dim_lw, ext_lw) = (weight(style.dim_lineweight), weight(style.ext_lineweight));
let (dim_lt, ext1_lt, ext2_lt) = (ltype(&style.dim_linetype), ltype(&style.ext_linetype1), ltype(&style.ext_linetype2));
for (i, (l, role)) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))).enumerate() {
let (rgb, w, t) = match role {
LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()),
LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()),
LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()),
};
let line_style = |i: usize| match g.line_roles.get(i).unwrap_or(&LineRole::Dim) {
LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()),
LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()),
LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()),
};
// The text's marks (arc length symbol, DIMINSPECT frame) come last, over the text background.
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) {
let (rgb, w, t) = line_style(i);
stroke_with(b, ctx, rgb, w, t, ltscale, l);
}
for t in &g.fills {
b.tris(ctx, dim_rgb, t);
}
dim_text_fill(b, ctx, e, &style, &g.text_fill);
for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) {
let (rgb, w, t) = line_style(i);
stroke_with(b, ctx, rgb, w, t, ltscale, l);
}
if b.opts.text {
let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0, text: g.text_runs.clone() };
b.shaped(ctx, txt_rgb, lw, &sh);
}
}
/// DIMTFILL: a dimension's text background (`corners` from [`DimGeometry::text_fill`]) hides
/// what is drawn before it, in the drawing's background color (1) or DIMTFILLCLR (2).
fn dim_text_fill(b: &mut Builder, ctx: &Ctx, e: &Entity, style: &cadcraft_doc::DimStyle, corners: &[Vec2]) {
let [p0, p1, p2, p3] = corners else { return };
if !b.opts.fill || style.text_fill == 0 {
return;
}
// DIMTFILLCLR ByBlock = the dimension's own color, as DIMCLRT.
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
let own = match e.common.color {
Color::ByLayer => layer_color,
Color::ByBlock => ctx.block_color,
c => c,
};
let n = b.list.prims.len();
b.tris(ctx, ink(style.text_fill_color, layer_color, own), &[*p0, *p1, *p2, *p0, *p2, *p3]);
if style.text_fill == 1
&& let Some(p) = b.list.prims.get_mut(n)
{
p.kind = Kind::Mask;
}
}
/// A dimension block's text, or a line that is a segment of one of the text's marks in `g`
/// (as the `*D` block writer splits them): where the background goes.
fn starts_text_marks(be: &Entity, g: &DimGeometry) -> bool {
match &be.kind {
EntityKind::MText(_) | EntityKind::Text(_) => true,
EntityKind::Line(l) => {
let (a, b) = (l.a.xy(), l.b.xy());
g.text_marks
.iter()
.filter_map(|i| g.lines.get(*i))
.any(|m| m.windows(2).any(|w| matches!(w, [p, q] if p.dist(a) < 1e-6 && q.dist(b) < 1e-6)))
}
_ => false,
}
}
/// The background of a dimension block's MTEXT or TEXT: its box widened by `gap` on every side,
/// turned with the text (corners in order); `None` for other entities.
fn block_text_box(d: &Drawing, be: &Entity, gap: f64) -> Option<Vec<Vec2>> {
let t = match &be.kind {
EntityKind::MText(t) => t,
EntityKind::Text(t) => return text_box(d, t, gap),
_ => return None,
};
let (bounds, at, rot) = (layout_mtext_entity(d, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }).bounds, t.insert.xy(), t.rotation);
if !(bounds.width().is_finite() && bounds.height().is_finite()) || bounds.width() <= 0.0 {
return None;
}
let (lo, hi) = (bounds.min - Vec2::new(gap, gap), bounds.max + Vec2::new(gap, gap));
let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp());
let turn = |p: Vec2| at + x * (p.x - at.x) + y * (p.y - at.y);
Some([lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)].into_iter().map(turn).collect())
}
/// The background of a TEXT as placed: the box of its strokes and glyph outlines along the text's
/// direction (the baseline from the insertion to the alignment point for Aligned and Fit),
/// widened by `gap` on every side.
fn text_box(d: &Drawing, t: &cadcraft_doc::Text, gap: f64) -> Option<Vec<Vec2>> {
let (sh, _) = place_text_entity(d, t, &t.value);
let rot = match (t.halign, t.align_pt) {
(cadcraft_doc::HAlign::Aligned | cadcraft_doc::HAlign::Fit, Some(p)) if p.xy().dist(t.insert.xy()) > 1e-12 => t.insert.xy().angle_to(p.xy()),
_ => t.rotation,
};
let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp());
let pts: Vec<Vec2> = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).copied().collect();
let first = pts.first()?;
let start = (first.dot(x), first.dot(y), first.dot(x), first.dot(y));
let (s0, u0, s1, u1) = pts.iter().fold(start, |(s0, u0, s1, u1), p| (s0.min(p.dot(x)), u0.min(p.dot(y)), s1.max(p.dot(x)), u1.max(p.dot(y))));
if !(s1 - s0 > 0.0 && (s0 + s1 + u0 + u1).is_finite()) {
return None;
}
let at = |s: f64, u: f64| x * s + y * u;
Some(vec![at(s0 - gap, u0 - gap), at(s1 + gap, u0 - gap), at(s1 + gap, u1 + gap), at(s0 - gap, u1 + gap)])
}
/// Most texts of one dimension block that get a DIMTFILL background, and most of its SOLIDs
/// checked for covering one.
const MAX_FILL_TEXTS: usize = 64;
const MAX_FILL_SOLIDS: usize = 256;
/// Whether `be` is a SOLID that covers `p` (a text background the block holds already, as the
/// `*D` block of DIMTFILL 2 does).
fn solid_covers(be: &Entity, p: Vec2) -> bool {
let EntityKind::Solid(s) = &be.kind else { return false };
// SOLID corners go 1, 2, 4, 3 around the outline.
let [c1, c2, c3, c4] = s.corners.map(|c| c.xy());
let ring = [c1, c2, c4, c3];
let next = [c2, c4, c3, c1];
let sides: Vec<f64> = ring.iter().zip(next.iter()).map(|(a, b)| (*b - *a).cross(p - *a)).filter(|c| c.abs() > 1e-12).collect();
!sides.is_empty() && (sides.iter().all(|c| *c > 0.0) || sides.iter().all(|c| *c < 0.0))
}
mod table;
pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of};
+268
View File
@@ -0,0 +1,268 @@
//! DIMTFILL and DIMTFILLCLR: a dimension's text background hides what is drawn under the text,
//! in the drawing's background color (1) or in DIMTFILLCLR (2).
use cadcraft_color::{Color, Rgb};
use cadcraft_doc::{Block, Circle, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, Handle, Line, MText, Space};
use cadcraft_geom::{Vec2, Vec3};
use cadcraft_render::raster::{RasterOptions, View, render};
use cadcraft_render::{DisplayList, Kind, Options, build, dimension_geometry};
/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5, in style "Fill".
fn dimension() -> Dimension {
Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(0.0, 5.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Fill".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
/// DIMGAP 0.5 leaves room above the text: a line at y = 5.4 passes under the background but
/// clear of the text.
fn style(fill: u8, color: Color) -> DimStyle {
DimStyle { name: "Fill".into(), text_gap: 0.5, text_fill: fill, text_fill_color: color, ..DimStyle::default() }
}
/// A line at y = 5.4 drawn first, then the dimension.
fn drawing(fill: u8, color: Color) -> Drawing {
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(fill, color));
let line = EntityKind::Line(Line { a: Vec3::new(-2.0, 5.4, 0.0), b: Vec3::new(12.0, 5.4, 0.0) });
d.add(&Space::Model, Common::default(), line).unwrap();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension())).unwrap();
d
}
/// Dark pixels on the row of the line at y = 5.4, near the text (x 4.6 to 5.4) and far from it
/// (x -1.5 to -0.5), on white paper.
fn dark_on_line(list: &DisplayList) -> (usize, usize) {
let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 };
let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() };
let pm = render(list, &view, &o).unwrap();
let (mut near, mut far) = (0, 0);
// World y = 5.4 is screen row 100 - 0.4 * 20 = 92; world x = 5 + (px - 200) / 20.
for y in 91..=93u32 {
for x in 0..400u32 {
let wx = 5.0 + (f64::from(x) + 0.5 - 200.0) / 20.0;
if pm.pixel(x, y).unwrap().red() > 128 {
continue;
}
if (4.6..5.4).contains(&wx) {
near += 1;
} else if (-1.5..-0.5).contains(&wx) {
far += 1;
}
}
}
(near, far)
}
#[test]
fn text_fill_gives_the_text_a_background_box() {
let d = dimension();
assert!(dimension_geometry(&d, &style(0, Color::ByBlock), 1.0).text_fill.is_empty(), "DIMTFILL 0: no background");
let g = dimension_geometry(&d, &style(1, Color::ByBlock), 1.0);
let [a, b, c, e] = g.text_fill.as_slice() else { panic!("four corners: {:?}", g.text_fill) };
// The box is centered on the text and reaches DIMGAP (0.5) past the text height (0.18).
let mid = (*a + *b + *c + *e) * 0.25;
assert!(mid.dist(g.text_pos) < 1e-9, "{mid:?} vs {:?}", g.text_pos);
assert!(((c.y - a.y) - (0.18 + 1.0)).abs() < 0.05, "height {}", c.y - a.y);
assert!(b.x - a.x > 1.0, "width {}", b.x - a.x);
}
#[test]
fn text_fill_hides_what_is_drawn_under_the_text() {
let opts = Options::default();
let (near, far) = dark_on_line(&build(&drawing(0, Color::ByBlock), &Space::Model, &opts));
assert!(near > 0 && far > 0, "DIMTFILL 0: the line shows under the text ({near}, {far})");
let list = build(&drawing(1, Color::ByBlock), &Space::Model, &opts);
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1);
let (near, far) = dark_on_line(&list);
assert_eq!(near, 0, "DIMTFILL 1: the background hides the line under the text");
assert!(far > 0, "the line shows away from the text");
}
#[test]
fn inspection_frame_is_drawn_over_the_background() {
// Polylines drawn after the background, for the dimension with and without an inspection
// frame (the text strokes come after it either way).
let after_mask = |dm: Dimension| {
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(1, Color::ByBlock));
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let list = build(&d, &Space::Model, &Options::default());
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).unwrap();
list.prims[mask..].iter().filter(|p| p.kind == Kind::Polyline).count()
};
let mut dm = dimension();
// No label or rate: the same text, and a frame of top, bottom and two ends.
dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() });
let frame = dimension_geometry(&dm, &style(1, Color::ByBlock), 1.0).text_marks.len();
assert_eq!(frame, 4);
let (plain, inspected) = (after_mask(dimension()), after_mask(dm));
assert_eq!(inspected, plain + frame, "the frame lines come after the background");
// An arc length dimension's symbol over the text (DIMARCSYM 1) is a mark of the text too.
let arc = Dimension {
kind: DimKind::ArcLength,
defpt: Vec3::new(0.0, 6.0, 0.0),
p13: Vec3::new(5.0, 0.0, 0.0),
p14: Vec3::new(-5.0, 0.0, 0.0),
p15: Vec3::ZERO,
..dimension()
};
let st = DimStyle { arc_symbol: 1, ..style(1, Color::ByBlock) };
let g = dimension_geometry(&arc, &st, 1.0);
let [sym] = g.text_marks.as_slice() else { panic!("one mark: {:?}", g.text_marks) };
assert!(g.lines[*sym].len() == 13, "the 13-point arc over the text");
}
#[test]
fn a_dimension_read_with_its_block_keeps_the_block_and_gets_the_background() {
// The block (as read from a file) holds a circle the dimension's own geometry does not have,
// then the text, placed away from where CADCraft would put it.
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(1, Color::ByBlock));
let mut blk = Block::new("*D1");
let circle = EntityKind::Circle(Circle { center: Vec3::new(5.0, 5.0, 0.0), radius: 3.0 });
blk.entities.push(Entity::new(Handle(1), circle));
let text = MText {
insert: Vec3::new(20.0, 20.0, 0.0),
height: 0.18,
width: 0.0,
attach: 5,
rotation: 0.0,
style: "Standard".into(),
contents: "10.0000".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text)));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
let list = build(&d, &Space::Model, &Options::default());
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background");
let circle = list.prims.iter().position(|p| list.points(p).iter().any(|q| q.dist(Vec2::new(8.0, 5.0)) < 0.05)).expect("the block's circle");
assert!(circle < mask, "the circle before the background, the text after it");
// The background is around the block's text.
let pts = list.points(&list.prims[mask]);
let mid = pts.iter().fold(Vec2::ZERO, |s, p| s + *p) * (1.0 / pts.len() as f64);
assert!(mid.dist(Vec2::new(20.0, 20.0)) < 0.05, "{mid:?}");
}
#[test]
fn every_text_of_a_block_gets_a_background() {
// A measurement and a tolerance as two MTEXTs of the block.
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(1, Color::ByBlock));
let mut blk = Block::new("*D1");
for (i, (y, s)) in [(20.0, "10.0000"), (22.0, "+0.1")].into_iter().enumerate() {
let text = MText {
insert: Vec3::new(20.0, y, 0.0),
height: 0.18,
width: 0.0,
attach: 5,
rotation: 0.0,
style: "Standard".into(),
contents: s.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
blk.entities.push(Entity::new(Handle(i as u64 + 1), EntityKind::MText(text)));
}
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
let list = build(&d, &Space::Model, &Options::default());
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 2);
}
#[test]
fn a_block_that_holds_its_background_gets_no_second_one() {
// A block with a SOLID, then its text at (20, 20), away from where CADCraft would put it: the
// number of masks drawn.
let masks = |solid: [(f64, f64); 4]| {
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(1, Color::ByBlock));
let mut blk = Block::new("*D1");
let solid = cadcraft_doc::Solid { corners: solid.map(|(x, y)| Vec3::new(x, y, 0.0)) };
blk.entities.push(Entity::new(Handle(1), EntityKind::Solid(solid)));
let text = MText {
insert: Vec3::new(20.0, 20.0, 0.0),
height: 0.18,
width: 0.0,
attach: 5,
rotation: 0.0,
style: "Standard".into(),
contents: "10.0000".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text)));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
let list = build(&d, &Space::Model, &Options::default());
list.prims.iter().filter(|p| p.kind == Kind::Mask).count()
};
assert_eq!(masks([(19.0, 19.5), (21.0, 19.5), (19.0, 20.5), (21.0, 20.5)]), 0, "the SOLID covers the text");
// A small SOLID (an arrowhead) over the middle of the text only: the background is drawn.
assert_eq!(masks([(19.9, 19.95), (20.1, 20.0), (19.9, 20.05), (19.9, 20.05)]), 1, "the SOLID covers part of the text");
}
#[test]
fn a_block_with_turned_text_gets_a_turned_background() {
// A block whose text is a TEXT turned 90 degrees, at (20, 20): the background is taller than
// wide and holds every point of the text.
let mut d = Drawing::new_imperial();
d.dim_styles.push(style(1, Color::ByBlock));
let mut blk = Block::new("*D1");
let text = cadcraft_doc::Text {
insert: Vec3::new(20.0, 20.0, 0.0),
align_pt: None,
height: 0.5,
value: "10.00".into(),
rotation: std::f64::consts::FRAC_PI_2,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: Default::default(),
valign: Default::default(),
};
blk.entities.push(Entity::new(Handle(1), EntityKind::Text(text)));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap();
let list = build(&d, &Space::Model, &Options::default());
let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background");
let quad = list.points(&list.prims[mask]);
let (lo, hi) = quad.iter().fold((quad[0], quad[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
assert!(hi.y - lo.y > 1.5 * (hi.x - lo.x), "turned: {lo:?} {hi:?}");
let text: Vec<Vec2> = list.prims[mask + 1..].iter().flat_map(|p| list.points(p).to_vec()).collect();
assert!(!text.is_empty() && text.iter().all(|p| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y), "the text inside {lo:?} {hi:?}");
}
#[test]
fn text_fill_2_paints_dimtfillclr() {
let list = build(&drawing(2, Color::Index(1)), &Space::Model, &Options::default());
assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 0);
let red: Vec<_> = list.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)).collect();
assert_eq!(red.len(), 1, "one red background");
// Over the line, next to the text: the background is drawn after the line.
let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 };
let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() };
let pm = render(&list, &view, &o).unwrap();
let c = pm.pixel(194, 92).unwrap();
assert_eq!((c.red(), c.green(), c.blue()), (255, 0, 0));
}
+1
View File
@@ -2,6 +2,7 @@ mod aci7_display;
mod annotative_viewport;
mod dim_angular_centre;
mod dim_post_tolerance;
mod dim_text_fill;
mod dim_text_format;
mod hatch_fill;
mod linetype_text;
+3
View File
@@ -85,6 +85,9 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf
if ui.button("▶").on_hover_text(crate::tl!("Next Change")).clicked() {
action = Some("compare.next");
}
if ui.button(crate::tl!("Export")).on_hover_text(crate::tl!("Export Comparison")).clicked() {
action = Some("compareexport");
}
if ui.button("×").on_hover_text(crate::tl!("Close Comparison")).clicked() {
action = Some("compareclose");
}
+1 -1
View File
@@ -452,7 +452,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
}
match serde_json::from_value::<crate::UiState>(cur) {
Ok(u) => {
if let Err(error) = u.docking.validate() {
if let Err(error) = crate::docking::validate(&u.docking) {
return err(error);
}
app.ui = u;
+125 -224
View File
@@ -1,7 +1,10 @@
//! Application panel identities and persistence around the shared docking renderer.
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
use craft_ui::docking::{
DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock,
Zone,
};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -40,25 +43,13 @@ impl Panel {
Self::ExternalRefs => "External References",
}
}
fn default_side(self) -> Zone {
match self {
Self::Canvas => Zone::Right,
Self::ToolSets => Zone::Left,
Self::QuickCalc => Zone::Right,
Self::Layers => Zone::Right,
Self::Properties => Zone::Right,
Self::ExternalRefs => Zone::Right,
}
}
/// Palettes outside the default arrangement, opened on request (QUICKCALC).
pub const ON_DEMAND: &[Self] = &[Self::QuickCalc];
fn parse(id: &str) -> Result<Self, String> {
Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
}
}
fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
/// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it.
pub fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node<Panel> {
@@ -69,230 +60,137 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels.
pub type Workspace = craft_ui::docking::Workspace<Panel>;
/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right.
pub fn default_workspace() -> Workspace {
Workspace::new(Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(220.0),
tabs(Panel::ToolSets),
split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(300.0),
tabs(Panel::Canvas),
Node::Stack {
entries: vec![
StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
StackEntry { panel: Panel::Properties, open: true, height: None },
],
},
),
)),
floating: Vec::new(),
})
}
impl Default for Workspace {
fn default() -> Self {
Self {
layout: Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(220.0),
tabs(Panel::ToolSets),
split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(300.0),
tabs(Panel::Canvas),
Node::Stack {
entries: vec![
craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None },
],
},
),
)),
floating: Vec::new(),
},
hidden: Vec::new(),
/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float,
/// group or collapse it, or restore a palette over it.
pub fn validate(workspace: &Workspace) -> Result<(), String> {
workspace.validate().map_err(|error| error.to_string())?;
if !workspace.layout.contains(&Panel::Canvas) {
return Err("The panel layout must contain its document view".into());
}
if workspace.layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) {
return Err("The document view cannot float".into());
}
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
return Err("The document view cannot share a tab group".into());
}
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
return Err("The document view cannot be collapsed".into());
}
Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => {
return Err("The document view cannot be in a dock".into());
}
_ => {}
}
}
let over_canvas = |location: &Location<Panel>| {
location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center))
};
if workspace.hidden.iter().any(|(_, location)| over_canvas(location)) {
return Err("A hidden panel cannot restore over the document view".into());
}
Ok(())
}
impl Workspace {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate().map_err(|error| error.to_string())?;
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() {
return Err("The panel layout must contain its document view and bounded hidden panels".into());
}
// Saved settings cannot turn the protected document view into a floating or closable tab.
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
return Err("The document view cannot float".into());
}
let mut nodes: Vec<_> = self.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
return Err("The document view cannot share a tab group".into());
}
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
return Err("The document view cannot be collapsed".into());
}
_ => {}
}
}
let mut seen = std::collections::HashSet::new();
for (panel, location) in &self.hidden {
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
return Err("A hidden panel has an invalid or duplicate identity".into());
}
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
return Err("A hidden panel cannot restore over the document view".into());
}
let mut probe = self.layout.clone();
if let Err(error) = probe.restore(*panel, location)
&& error != craft_ui::docking::DockError::MissingTarget
{
return Err(error.to_string());
}
}
Ok(())
}
/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place
/// for), keeping the drawing view's rules.
pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
let mut next = workspace.clone();
next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?;
validate(&next)?;
*workspace = next;
Ok(())
}
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
self.validate()?;
let mut next = self.clone();
if let Action::Close { panel } = &action {
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
next.hidden.retain(|(id, _)| id != panel);
next.hidden.push((*panel, location));
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
next.hidden.retain(|(id, _)| id != panel);
}
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
next.validate()?;
*self = next;
Ok(())
/// Show or hide a panel for the Tool Sets and palette switches. Showing a panel whose recorded
/// anchor is shown restores it there without changing which tab is selected or which accordion
/// entries are open. Otherwise it goes through [`reveal_panel`], which regroups it with hidden
/// panels it was beside, and selects it.
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
if workspace.layout.contains(&panel) == visible {
return Ok(());
}
/// Explicit opens select the requested tab and expand a collapsed stack entry.
pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> {
let mut next = self.clone();
next.set_visible(panel, true)?;
next.apply(Action::Activate { panel })?;
*self = next;
Ok(())
if !visible {
return workspace.set_visible(panel, false, None, permissions).map_err(|error| error.to_string());
}
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
if self.layout.contains(&panel) == visible {
return Ok(());
}
if !visible {
return self.apply(Action::Close { panel });
}
self.validate()?;
let mut next = self.clone();
let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
// Recreate the last surviving member first. Restoring a member also restores any
// hidden anchor it needs, so either close order retains the original tab order.
// Remove this member before recursion to bound malformed/cyclic saved anchors.
let mut next = workspace.clone();
let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
// A direct restore against a hidden anchor would open a window of its own.
let direct = location.filter(|location| location.anchor.is_none_or(|anchor| next.layout.contains(&anchor)));
if direct.is_some_and(|location| next.layout.restore(panel, &location).is_ok()) {
next.hidden.retain(|(id, _)| *id != panel);
if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor)
&& !next.layout.contains(&anchor)
&& next.hidden.iter().any(|(id, _)| *id == anchor)
{
next.set_visible(anchor, true)?;
}
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
if !restored {
next.layout
.apply_with_permissions(
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
permissions,
)
.map_err(|error| error.to_string())?;
}
next.validate()?;
*self = next;
Ok(())
validate(&next)?;
*workspace = next;
return Ok(());
}
reveal_panel(workspace, panel)
}
fn command(&mut self, params: &Value) -> Result<(), String> {
self.validate()?;
if let Some(action) = params.get("action") {
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
return self.apply(action);
/// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar.
fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
validate(workspace)?;
let operation = if let Some(action) = params.get("action") {
UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? }
} else {
UiDock::Operation(serde_json::from_value(params.clone()).map_err(|error| error.to_string())?)
};
let mut next = workspace.clone();
match operation {
UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel }) if !next.layout.contains(&panel) => {
reveal_panel(&mut next, panel)?;
}
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if operation == "resizeSplit" {
let path: Vec<bool> =
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
let size: SplitSize =
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
return self.apply(Action::ResizeSplit { path, size });
operation => {
// Every ui.dock operation carries its own rect, so the float space is never consulted.
let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)));
DockCommand::UiDock(operation).apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?;
}
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
let action = match operation {
"open" => return self.reveal_panel(panel),
"close" => Action::Close { panel },
"activate" => Action::Activate { panel },
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
"resizeStack" => {
let value = params.get("height").ok_or("height is required")?;
let height = if value.is_null() {
None
} else {
Some(
value
.as_f64()
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
.ok_or("height must be finite and bounded")? as f32,
)
};
Action::ResizeStack { panel, height }
}
"float" | "moveFloating" => {
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
let mut values = [0.0f32; 4];
for (output, value) in values.iter_mut().zip(rect) {
*output = value
.as_f64()
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
.ok_or("rect values must be finite and bounded")? as f32;
}
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
}
"move" => {
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be center, left, right, top or bottom".into()),
};
let placement = if let Some(before) = params.get("before") {
if !matches!(placement, Placement::Split(Zone::Center)) {
return Err("before only applies to a center move".into());
}
Placement::Tab {
before: if before.is_null() {
None
} else {
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
},
}
} else {
placement
};
Action::Move { panel, anchor, placement }
}
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
};
self.apply(action)
}
validate(&next)?;
*workspace = next;
Ok(())
}
fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {
workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?;
set_visible(workspace, Panel::ToolSets, app.ui.show_toolsets)?;
if !app.ui.show_palettes {
workspace.set_visible(Panel::Layers, false)?;
workspace.set_visible(Panel::Properties, false)?;
set_visible(workspace, Panel::Layers, false)?;
set_visible(workspace, Panel::Properties, false)?;
} else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) {
// Reopen the last surviving accordion entry before restoring its earlier sibling.
workspace.set_visible(Panel::Properties, true)?;
workspace.set_visible(Panel::Layers, true)?;
set_visible(workspace, Panel::Properties, true)?;
set_visible(workspace, Panel::Layers, true)?;
}
Ok(())
}
@@ -305,10 +203,10 @@ fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {
pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<Value, String> {
let mut workspace = app.ui.docking.clone();
if reset {
workspace = Workspace::default();
workspace = default_workspace();
} else {
sync(&mut workspace, app)?;
workspace.command(params)?;
apply_command(&mut workspace, params)?;
}
publish_visibility(app, &workspace);
app.ui.docking = workspace;
@@ -318,9 +216,9 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get();
let mut workspace = app.ui.docking.clone();
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) {
app.set_status(format!("Panel layout could not be restored: {error}"));
workspace = Workspace::default();
workspace = default_workspace();
let _ = sync(&mut workspace, app);
}
// Bodies can dispatch commands (including the drawing's typed command line).
@@ -336,6 +234,9 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.font = crate::theme::body();
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
style.new_dock_zones = false;
style.title_bar_docks = false;
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
ui,
&workspace.layout,
@@ -370,7 +271,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let rendered = workspace;
let mut workspace = app.ui.docking.clone();
let _ = sync(&mut workspace, app);
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
let stale = workspace != rendered;
if stale {
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
}
+4
View File
@@ -150,6 +150,7 @@
Close All Other Drawings إغلاق كل الرسومات الأخرى
Close Array Editing إغلاق تحرير المصفوفة
Close Comparison إغلاق المقارنة
Export Comparison تصدير المقارنة
Closed مغلق
Coincident تطابق
Collapse Palettes طي اللوحات
@@ -203,6 +204,7 @@
Current selection التحديد الحالي
Current: {color} الحالي: {color}
Cut قص
Cut with Base Point قص من نقطة أساس
Cyan سماوي
Cylinder أسطوانة
DWG DWG
@@ -614,6 +616,7 @@
No supported 3D mouse found لم يُعثر على فأرة ثلاثية الأبعاد مدعومة
Node عقدة
None لا شيء
Normal عادي
Not Found غير موجود
Not available غير متاح
Not available yet غير متاح بعد
@@ -785,6 +788,7 @@
Rename Text Style إعادة تسمية نمط النص
Rename... إعادة تسمية...
Repeat {command} تكرار {command}
Replace Block... استبدال الكتلة...
Requires a point (x,y, @dx,dy or @d<a). يتطلب نقطة (x,y أو @dx,dy أو @d<a).
Reset Palettes إعادة تعيين اللوحات
Reset Panel Layout إعادة تعيين تخطيط اللوحات
1 # CADCraft Arabic interface catalog — original translations.
150
151
152
153
154
155
156
204
205
206
207
208
209
210
616
617
618
619
620
621
622
788
789
790
791
792
793
794
+4
View File
@@ -141,6 +141,7 @@
Close All Other Drawings Zavřít všechny ostatní výkresy
Closed Uzavřeno
Close Comparison Zavřít porovnání
Export Comparison Exportovat porovnání
Coincident Totožnost
Collapse Tool Sets Sbalit sady nástrojů
Collinear Kolinearita
@@ -186,6 +187,7 @@
Current selection Aktuální výběr
Current: {color} Aktuální: {color}
Cut Vyjmout
Cut with Base Point Vyjmout se základním bodem
Cyan Azurová
Cylinder Válec
DWG DWG
@@ -558,6 +560,7 @@
No selection Žádný výběr
Node Bod
None Žádné
Normal Normální
Not available Není k dispozici
Not available yet Zatím není k dispozici
Numbered list Číslovaný seznam
@@ -730,6 +733,7 @@
Rename Text Style Přejmenovat styl textu
Rename... Přejmenovat...
Repeat {command} Opakovat {command}
Replace Block... Nahradit blok...
Requires a point (x,y, @dx,dy or @d<a). Je vyžadován bod (x,y, @dx,dy nebo @d<a).
Reset Palettes Obnovit palety
Restore Obnovit
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
141
142
143
144
145
146
147
187
188
189
190
191
192
193
560
561
562
563
564
565
566
733
734
735
736
737
738
739
+4
View File
@@ -145,6 +145,7 @@
Current selection Aktuelle Auswahl
Current: {color} Aktuell: {color}
Cut Ausschneiden
Cut with Base Point Mit Basispunkt ausschneiden
Cyan Cyan
Cylinder Zylinder
DWG DWG
@@ -476,6 +477,7 @@
No selection Keine Auswahl
Node Knoten
None Keine
Normal Normal
Not available Nicht verfügbar
Not available yet Noch nicht verfügbar
OK OK
@@ -622,6 +624,7 @@
Rename Text Style Textstil umbenennen
Rename... Umbenennen...
Repeat {command} {command} wiederholen
Replace Block... Block ersetzen...
Requires a point (x,y, @dx,dy or @d<a). Erfordert einen Punkt (x,y, @dx,dy oder @d<a).
Reset Palettes Paletten zurücksetzen
Restore Wiederherstellen
@@ -967,6 +970,7 @@
Viewport Properties Ansichtsfenster-Eigenschaften
Viewport {id} Ansichtsfenster {id}
Viewports Ansichtsfenster
Volume Volumen
W W
WCS WKS
White Weiß
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
145
146
147
148
149
150
151
477
478
479
480
481
482
483
624
625
626
627
628
629
630
970
971
972
973
974
975
976
+3
View File
@@ -165,6 +165,7 @@
Close Array Editing Κλείσιμο επεξεργασίας διάταξης
Close Block Editor Κλείσιμο επεξεργαστή μπλοκ
Close Comparison Κλείσιμο σύγκρισης
Export Comparison Εξαγωγή σύγκρισης
Closed Κλειστό
Coincident Σύμπτωση (Coincident)
Collapse Palettes Σύμπτυξη παλετών
@@ -227,6 +228,7 @@
Curved Καμπύλο
Custom Προσαρμοσμένο
Cut Αποκοπή
Cut with Base Point Αποκοπή με σημείο βάσης
Cyan Κυανό
Cylinder Κύλινδρος
Dark Σκούρο
@@ -852,6 +854,7 @@
Rename Text Style Μετονομασία στυλ κειμένου
Rename... Μετονομασία...
Repeat {command} Επανάληψη εντολής {command}
Replace Block... Αντικατάσταση μπλοκ...
Requires a point (x,y, @dx,dy or @d<a). Απαιτείται σημείο (x,y, @dx,dy ή @d<a).
Reset keeps the interface language. Η επαναφορά διατηρεί τη γλώσσα του περιβάλλοντος.
Reset Palettes Επαναφορά παλετών
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
165
166
167
168
169
170
171
228
229
230
231
232
233
234
854
855
856
857
858
859
860
+4
View File
@@ -140,6 +140,7 @@
Close All Other Drawings Cerrar todos los demás dibujos
Close Array Editing Cerrar la edición de matriz
Close Comparison Cerrar comparación
Export Comparison Exportar comparación
Closed Cerrada
Coincident Coincidente
Collapse Palettes Contraer paletas
@@ -192,6 +193,7 @@
Current selection Selección actual
Current: {color} Actual: {color}
Cut Cortar
Cut with Base Point Cortar con punto base
Cyan Cian
Cylinder Cilindro
DWG DWG
@@ -589,6 +591,7 @@
No selection Sin selección
Node Nodo
None Ninguno
Normal Normal
Not Found No encontrada
Not available No disponible
Not available yet Aún no disponible
@@ -828,6 +831,7 @@
Rename Text Style Renombrar estilo de texto
Rename... Renombrar...
Repeat {command} Repetir {command}
Replace Block... Reemplazar bloque...
Requires a point (x,y, @dx,dy or @d<a). Se requiere un punto (x,y, @dx,dy o @d<a).
Reset Palettes Restablecer paletas
Restore Restaurar
1 # CADCraft Spanish interface catalog — original translations.
140
141
142
143
144
145
146
193
194
195
196
197
198
199
591
592
593
594
595
596
597
831
832
833
834
835
836
837
+4
View File
@@ -144,6 +144,7 @@
Current selection Sélection actuelle
Current: {color} Actuel : {color}
Cut Couper
Cut with Base Point Couper avec point de base
Cyan Cyan
Cylinder Cylindre
DWG DWG
@@ -471,6 +472,7 @@
No selection Aucune sélection
Node Nœud
None Aucun
Normal Normal
Not available Non disponible
Not available yet Pas encore disponible
OK OK
@@ -616,6 +618,7 @@
Rename Text Style Renommer le style de texte
Rename... Renommer...
Repeat {command} Répéter {command}
Replace Block... Remplacer le bloc...
Requires a point (x,y, @dx,dy or @d<a). Nécessite un point (x,y, @dx,dy ou @d<a).
Reset Palettes Réinitialiser les palettes
Restore Restaurer
@@ -961,6 +964,7 @@
Viewport Properties Propriétés de la fenêtre
Viewport {id} Fenêtre {id}
Viewports Fenêtres
Volume Volume
W O
WCS SCG
White Blanc
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
144
145
146
147
148
149
150
472
473
474
475
476
477
478
618
619
620
621
622
623
624
964
965
966
967
968
969
970
+3
View File
@@ -178,6 +178,7 @@
Close Array Editing Kiosztás szerkesztésének lezárása
Close Block Editor Blokkszerkesztő bezárása
Close Comparison Összehasonlítás bezárása
Export Comparison Összehasonlítás exportálása
Closed Zárt
Coincident Egybeeső
Collapse Palettes Paletták összecsukása
@@ -239,6 +240,7 @@
Curved Ívelt
Custom Egyéni
Cut Kivágás
Cut with Base Point Kivágás bázisponttal
Cyan Cián
Cylinder Henger
DWG DWG
@@ -861,6 +863,7 @@
Rename Text Style Szövegstílus átnevezése
Rename... Átnevezés...
Repeat {command} Ismétlés: {command}
Replace Block... Blokk cseréje...
Requires a point (x,y, @dx,dy or @d<a). Pontot kell megadni (x,y, @dx,dy vagy @d<a).
Reset Palettes Paletták visszaállítása
Reset Panel Layout Panelelrendezés visszaállítása
Can't render this file because it contains an unexpected character in line 16 and column 72.
+3
View File
@@ -147,6 +147,7 @@ menu Modify Edita
Close All Other Drawings Chiudi tutti gli altri disegni
Closed Chiusa
Close Comparison Chiudi confronto
Export Comparison Esporta confronto
Coincident Coincidente
Collapse Palettes Comprimi tavolozze
Collapse Tool Sets Comprimi set di strumenti
@@ -194,6 +195,7 @@ menu Modify Edita
Current selection Selezione corrente
Current: {color} Corrente: {color}
Cut Taglia
Cut with Base Point Taglia con punto base
Cyan Ciano
Cylinder Cilindro
DWG DWG
@@ -742,6 +744,7 @@ menu Modify Edita
Rename Text Style Rinomina stile di testo
Rename... Rinomina...
Repeat {command} Ripeti {command}
Replace Block... Sostituisci blocco...
Requires a point (x,y, @dx,dy or @d<a). Richiede un punto (x,y, @dx,dy o @d<a).
Reset Palettes Reimposta tavolozze
Restore Ripristina
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
147
148
149
150
151
152
153
195
196
197
198
199
200
201
744
745
746
747
748
749
750
+4
View File
@@ -141,6 +141,7 @@
Close All Other Drawings 다른 도면 모두 닫기
Closed 닫힘
Close Comparison 비교 닫기
Export Comparison 비교 내보내기
Coincident 일치
Collapse Palettes 팔레트 접기
Collapse Tool Sets 도구 세트 접기
@@ -187,6 +188,7 @@
Current selection 현재 선택 항목
Current: {color} 현재: {color}
Cut 잘라내기
Cut with Base Point 기준점을 지정하여 잘라내기
Cyan 청록색
Cylinder 원기둥
DWG DWG
@@ -560,6 +562,7 @@
No selection 선택 항목 없음
Node 노드
None 없음
Normal 보통
Not available 사용할 수 없음
Not available yet 아직 사용할 수 없음
Numbered list 번호 매기기 목록
@@ -732,6 +735,7 @@
Rename Text Style 문자 스타일 이름 바꾸기
Rename... 이름 바꾸기...
Repeat {command} {command} 반복
Replace Block... 블록 바꾸기...
Requires a point (x,y, @dx,dy or @d<a). 점이 필요합니다(x,y, @dx,dy 또는 @d<a).
Reset Palettes 팔레트 초기화
Restore 복원
1 # CADCraft Korean interface catalog — original Korean translations.
141
142
143
144
145
146
147
188
189
190
191
192
193
194
562
563
564
565
566
567
568
735
736
737
738
739
740
741
+4
View File
@@ -139,6 +139,7 @@
Close All Other Drawings Fechar todos os outros desenhos
Close Array Editing Fechar edição de matriz
Close Comparison Fechar comparação
Export Comparison Exportar comparação
Closed Fechada
Coincident Coincidente
Collapse Palettes Recolher paletas
@@ -191,6 +192,7 @@
Current selection Seleção atual
Current: {color} Atual: {color}
Cut Recortar
Cut with Base Point Recortar com ponto base
Cyan Ciano
Cylinder Cilindro
DWG DWG
@@ -587,6 +589,7 @@
No selection Nenhuma seleção
Node Nó
None Nenhum
Normal Normal
Not Found Não encontrado
Not available Não disponível
Not available yet Ainda não disponível
@@ -820,6 +823,7 @@
Rename Text Style Renomear estilo de texto
Rename... Renomear...
Repeat {command} Repetir {command}
Replace Block... Substituir bloco...
Requires a point (x,y, @dx,dy or @d<a). Requer um ponto (x,y, @dx,dy ou @d<a).
Reset Palettes Redefinir paletas
Restore Restaurar
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
139
140
141
142
143
144
145
192
193
194
195
196
197
198
589
590
591
592
593
594
595
823
824
825
826
827
828
829
+4
View File
@@ -144,6 +144,7 @@
Current selection Текущее выделение
Current: {color} Текущий: {color}
Cut Вырезать
Cut with Base Point Вырезать с базовой точкой
Cyan Голубой
Cylinder Цилиндр
DWG DWG
@@ -471,6 +472,7 @@
No selection Ничего не выделено
Node Узел
None Нет
Normal Обычный
Not available Недоступно
Not available yet Пока недоступно
OK ОК
@@ -616,6 +618,7 @@
Rename Text Style Переименовать текстовый стиль
Rename... Переименовать...
Repeat {command} Повторить {command}
Replace Block... Заменить блок...
Requires a point (x,y, @dx,dy or @d<a). Требуется точка (x,y, @dx,dy или @d<a).
Reset Palettes Сбросить палитры
Restore Восстановить
@@ -961,6 +964,7 @@
Viewport Properties Свойства видового экрана
Viewport {id} Видовой экран {id}
Viewports Видовые экраны
Volume Объём
W З
WCS МСК
White Белый
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
144
145
146
147
148
149
150
472
473
474
475
476
477
478
618
619
620
621
622
623
624
964
965
966
967
968
969
970
+3
View File
@@ -143,6 +143,7 @@
Close All Other Drawings Stäng alla andra ritningar
Close Array Editing Avsluta redigering av array
Close Comparison Stäng jämförelse
Export Comparison Exportera jämförelse
Closed Sluten
Coincident Sammanfallande
Collapse Palettes Fäll ihop paletter
@@ -195,6 +196,7 @@
Current selection Aktuell markering
Current: {color} Aktuell: {color}
Cut Klipp ut
Cut with Base Point Klipp ut med baspunkt
Cyan Cyan
Cylinder Cylinder
DWG DWG
@@ -824,6 +826,7 @@
Rename Text Style Byt namn på textstil
Rename... Byt namn...
Repeat {command} Upprepa {command}
Replace Block... Ersätt block...
Requires a point (x,y, @dx,dy or @d<a). Kräver en punkt (x,y, @dx,dy eller @d<a).
Reset Palettes Återställ paletter
Restore Återställ
1 # CADCraft Swedish interface catalog — original translations by the CADCraft contributors.
143
144
145
146
147
148
149
196
197
198
199
200
201
202
826
827
828
829
830
831
832
+3
View File
@@ -179,6 +179,7 @@
Close Array Editing Dizi Düzenlemeyi Kapat
Close Block Editor Blok Düzenleyiciyi Kapat
Close Comparison Karşılaştırmayı Kapat
Export Comparison Karşılaştırmayı Dışa Aktar
Closed Kapalı
Coincident Çakışık
Collapse Palettes Paletleri Daralt
@@ -240,6 +241,7 @@
Curved Eğri
Custom Özel
Cut Kes
Cut with Base Point Taban Noktasıyla Kes
Cyan Camgöbeği
Cylinder Silindir
DWG DWG
@@ -862,6 +864,7 @@
Rename Text Style Metin Stilini Yeniden Adlandır
Rename... Yeniden Adlandır...
Repeat {command} Yinele: {command}
Replace Block... Bloğu Değiştir...
Requires a point (x,y, @dx,dy or @d<a). Bir nokta gerekir (x,y, @dx,dy veya @d<a).
Reset Palettes Paletleri Sıfırla
Reset Panel Layout Panel Düzenini Sıfırla
Can't render this file because it contains an unexpected character in line 11 and column 13.
+3
View File
@@ -144,6 +144,7 @@
Close All Other Drawings Закрити всі інші креслення
Closed Замкнений
Close Comparison Закрити порівняння
Export Comparison Експортувати порівняння
Coincident Збіг
Collapse Palettes Згорнути палітри
Collapse Tool Sets Згорнути набори інструментів
@@ -191,6 +192,7 @@
Current selection Поточний вибір
Current: {color} Поточний: {color}
Cut Вирізати
Cut with Base Point Вирізати з базовою точкою
Cyan Блакитний
Cylinder Циліндр
DWG DWG
@@ -748,6 +750,7 @@
Rename Text Style Перейменувати текстовий стиль
Rename... Перейменувати...
Repeat {command} Повторити {command}
Replace Block... Замінити блок...
Requires a point (x,y, @dx,dy or @d<a). Потрібна точка (x,y, @dx,dy або @d<a).
Reset Palettes Скинути палітри
Restore Відновити
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
144
145
146
147
148
149
150
192
193
194
195
196
197
198
750
751
752
753
754
755
756
+3
View File
@@ -145,6 +145,7 @@
Current selection 当前选择
Current: {color} 当前: {color}
Cut 剪切
Cut with Base Point 带基点剪切
Cyan 青色
Cylinder 圆柱体
DWG DWG
@@ -617,6 +618,7 @@
Rename Text Style 重命名文字样式
Rename... 重命名...
Repeat {command} 重复 {command}
Replace Block... 替换块...
Requires a point (x,y, @dx,dy or @d<a). 需要一个点 (x,y, @dx,dy 或 @d<a)。
Reset Palettes 重置选项板
Restore 恢复
@@ -1161,6 +1163,7 @@
Over-constrained 过度约束
Previous Change 上一个更改
Close Comparison 关闭比较
Export Comparison 导出比较
Plotter Manager 打印机管理
Clear Command History 清除命令历史记录
Degrees of freedom: {n} 自由度: {n}
1 # CADCraft Simplified Chinese interface catalog — original translations.
145
146
147
148
149
150
151
618
619
620
621
622
623
624
1163
1164
1165
1166
1167
1168
1169
+1 -1
View File
@@ -156,7 +156,7 @@ impl SaveFormat {
impl Default for UiState {
fn default() -> Self {
UiState {
docking: docking::Workspace::default(),
docking: docking::default_workspace(),
interface_language: i18n::Preference::default(),
show_toolsets: true,
+77 -18
View File
@@ -52,19 +52,19 @@ fn real_panel_drag_float_close_reopen_and_full_workspace_reload() {
assert_eq!(location.anchor, Some(Panel::ToolSets));
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
harness.run_steps(3);
harness.get_by_label("Float panel").click();
harness.get_by_label("Float Panel").click();
harness.run_steps(5);
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect;
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect;
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
harness.run_steps(3);
harness.get_by_label("Close panel").click();
harness.get_by_label("Close Panel").click();
harness.run_steps(4);
assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers));
harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
harness.run_steps(3);
harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
harness.run_steps(4);
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect);
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect, rect);
let saved = serde_json::to_string(&harness.state().ui).unwrap();
let expected = serde_json::to_value(&harness.state().ui.docking).unwrap();
let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0);
@@ -88,6 +88,13 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() {
json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}),
json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}),
json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}),
json!({"operation":"float", "panel":"layers", "rect":[1e39,0,300,200]}),
json!({"operation":"resizeStack", "panel":"layers", "height":1e39}),
json!({"operation":"resizeSplit", "path":[true,true,true,true,true,true,true,true], "size":{"Ratio":0.5}}),
json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}),
json!({"operation":"spin", "panel":"layers"}),
json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}),
json!(null),
] {
assert!(harness.state_mut().run("ui.dock", params).is_err());
assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before);
@@ -171,28 +178,47 @@ fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing()
#[test]
fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() {
use cadcraft_ui_egui::docking::{default_workspace, permissions, reveal_panel};
use craft_ui::docking::{Action, Placement};
for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] {
let mut workspace = cadcraft_ui_egui::docking::Workspace::default();
let mut workspace = default_workspace();
let rect = [50.0, 60.0, 320.0, 400.0];
workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap();
workspace.apply(Action::Float { panel: Panel::Layers, rect }, permissions).unwrap();
workspace
.apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } })
.apply(
Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } },
permissions,
)
.unwrap();
let expected = workspace.layout.floating[0].panels.clone();
let expected: Vec<Panel> = workspace.layout.floating[0].panels().copied().collect();
for panel in order {
workspace.set_visible(panel, false).unwrap();
workspace.set_visible(panel, false, None, permissions).unwrap();
}
let saved = serde_json::to_string(&workspace).unwrap();
workspace = serde_json::from_str(&saved).unwrap();
workspace.reveal_panel(Panel::Properties).unwrap();
workspace.reveal_panel(Panel::Layers).unwrap();
reveal_panel(&mut workspace, Panel::Properties).unwrap();
reveal_panel(&mut workspace, Panel::Layers).unwrap();
assert_eq!(workspace.layout.floating.len(), 1);
assert_eq!(workspace.layout.floating[0].panels, expected);
assert_eq!(workspace.layout.floating[0].panels().copied().collect::<Vec<_>>(), expected);
assert_eq!(workspace.layout.floating[0].rect, rect);
}
}
#[test]
fn palettes_off_and_on_reopen_a_floating_group_as_one_window() {
let mut h = harness(None, 1280.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
h.run_steps(3);
h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
h.run_steps(3);
let floating = &h.state().ui.docking.layout.floating;
assert_eq!(floating.len(), 1, "{floating:?}");
assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}");
}
#[test]
fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
let mut harness = harness(None, 1000.0, 1.0);
@@ -206,8 +232,7 @@ fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
harness.run_steps(3);
let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap();
assert_eq!(location.anchor, Some(Panel::Layers));
let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
assert_eq!(harness.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front);
}
#[test]
@@ -266,8 +291,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac
h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
h.run_steps(3);
type_command(&mut h, "PROPERTIES");
let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
assert!(h.state().ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Properties)));
assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front);
assert_eq!(h.state().session.doc().unwrap(), &drawing);
}
@@ -278,7 +303,7 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() {
h.run_steps(3);
let drawing = h.state().session.doc().unwrap().clone();
type_command(&mut h, "ui.dock.reset");
let expected = cadcraft_ui_egui::docking::Workspace::default();
let expected = cadcraft_ui_egui::docking::default_workspace();
assert_eq!(h.state().ui.docking.layout, expected.layout);
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
assert_eq!(h.state().session.doc().unwrap(), &drawing);
@@ -319,7 +344,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
h.event(Event::Text("ui.dock.reset".into()));
h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
let expected = cadcraft_ui_egui::docking::Workspace::default();
let expected = cadcraft_ui_egui::docking::default_workspace();
assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command");
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree");
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
@@ -359,3 +384,37 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() {
h.run_steps(4);
assert!(h.state().ui.show_toolsets);
}
#[test]
fn group_drag_bar_moves_the_whole_group_beside_the_drawing() {
use craft_ui::docking::{DockArea, Hit, Node};
let mut h = harness(None, 1280.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap();
h.run_steps(4);
let bar = |h: &Harness<'_, CadApp>| {
DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking"))
.into_iter()
.find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers))
.map(|(_, rect)| rect)
};
let from = bar(&h).expect("the Tool Sets group draws a drag bar").center();
let drawing = h.state().canvas.rect.expect("the drawing view is drawn");
let to = egui::pos2(drawing.right() - 10.0, drawing.center().y);
let document = h.state().session.doc().unwrap().clone();
drag(&mut h, from, to);
let layout = &h.state().ui.docking.layout;
assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group");
// The drawing's frame splits: the drawing on the left, the whole group on the right.
let mut nodes: Vec<&Node<Panel>> = layout.root.iter().collect();
let mut beside = false;
while let Some(node) = nodes.pop() {
if let Node::Split { first, second, .. } = node {
beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. })
if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]);
nodes.push(first);
nodes.push(second);
}
}
assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root);
assert_eq!(h.state().session.doc().unwrap(), &document);
}
+3 -3
View File
@@ -43,7 +43,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() {
h.key_press_modifiers(modifiers, Key::Num8);
h.run_steps(5);
assert!(h.state().ui.docking.layout.contains(&Panel::QuickCalc));
let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect;
let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect;
expression(&h).click();
h.run_steps(3);
expression(&h).type_text("2+3");
@@ -55,7 +55,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() {
h.key_press_modifiers(modifiers, Key::Num8);
h.run_steps(5);
assert_eq!(h.get(by().role(Role::TextInput).value("2+3")).value().as_deref(), Some("2+3"));
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect, floating);
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect, floating);
assert_eq!(h.state().session.running.as_ref().unwrap().id, "line");
assert_eq!(h.state().cmd.buffer, "12,34");
assert_eq!(h.state().session.doc().unwrap(), &drawing);
@@ -131,7 +131,7 @@ fn explicit_opens_still_reveal_and_calculations_do_not_open_the_palette() {
app.run("ui.dock", json!({"operation":"activate", "panel":"toolSets"})).unwrap();
app.run("ui.panel.quickcalc", Value::Null).unwrap();
let mut workspace = app.ui.docking.clone();
workspace.reveal_panel(Panel::QuickCalc).unwrap();
cadcraft_ui_egui::docking::reveal_panel(&mut workspace, Panel::QuickCalc).unwrap();
assert_eq!(app.ui.docking.layout, workspace.layout, "explicit open must activate an inactive tab");
}
+3 -3
View File
@@ -134,7 +134,7 @@ Tool Sets, Layers, and Properties can be regrouped, split, floated within the ap
Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`.
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`, default `center`). Center and tab group with the target; side zones split beside it; above and below add a group in a floating window. `open` and `activate` show a hidden panel at its last place and select it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
The QuickCalc palette (`quickCalc`) is not part of the default arrangement. Typed `QUICKCALC`/`QC`
and `ui.panel.quickcalc` explicitly open and activate it: it floats the first time and later
@@ -150,9 +150,9 @@ Advanced `ui.dock` operations use the same validated transaction as pointer gest
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
A center `move` can include `before` (panel ID or null to append) for tab order.
A center or tab `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
this is also the form emitted by the renderer, and it takes every action of craft-ui's docking, group, column and window moves included. Protected-canvas and app-specific panel
rules apply equally to both forms.
## Command-line replies
+6 -3
View File
@@ -1,6 +1,6 @@
# Where CADCraft falls short of AutoCAD
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027
Every known shortfall, one row each, ranked within each section by user impact. This is the work
list: agents pick from here (top of a section first), and remove or update a row in the same PR
@@ -29,7 +29,7 @@ do them first.
| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 |
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 |
| F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
| F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
| F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 |
@@ -43,7 +43,7 @@ do them first.
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once, and has no COMPAREEXPORT report; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |
| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |
| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 |
| F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 |
@@ -150,6 +150,9 @@ Detail: [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated |
| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed |
| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |
| 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed |
| 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed |
| 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |
+6 -21
View File
@@ -1,20 +1,19 @@
# Parity checklist
> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand.
> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand.
> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand.
Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.
**Menu breadth: 283 / 491 items (58%)** · 427 registered commands.
**Menu breadth: 296 / 491 items (60%)** · 441 registered commands.
| Menu | Items | Covered | % |
|---|---|---|---|
| File | 22 | 13 | 59% |
| Edit | 13 | 11 | 85% |
| View | 75 | 31 | 41% |
| Edit | 13 | 12 | 92% |
| View | 75 | 36 | 48% |
| Insert | 11 | 5 | 45% |
| Format | 27 | 25 | 93% |
| Tools | 85 | 60 | 71% |
| Format | 27 | 26 | 96% |
| Tools | 85 | 66 | 78% |
| Draw | 87 | 55 | 63% |
| Dimension | 28 | 28 | 100% |
| Modify | 113 | 52 | 46% |
@@ -33,7 +32,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- File > Markup Import...
- File > Share...
- Edit > Undo Group of commands
- Edit > Cut with Base Point
- View > Pan > Point
- View > Orbit > Constrained Orbit
- View > Orbit > Free Orbit
@@ -68,14 +66,9 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- View > Render > Light > New Distant Light
- View > Render > Light > Light Glyphs
- View > Render > Materials Browser
- View > UCS Icon > On
- View > UCS Icon > Origin
- View > UCS Icon > Properties...
- View > ViewCube > On
- View > ViewCube > Settings
- View > Attribute Display > Normal
- View > Attribute Display > On
- View > Attribute Display > Off
- View > Lights
- View > Enter Full Screen
- Insert > PDF Underlay...
@@ -85,9 +78,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Insert > ACIS File...
- Insert > Drawing Exchange Binary...
- Format > Transparency
- Format > Thickness
- Tools > Spelling
- Tools > Inquiry > Volume
- Tools > Update Fields
- Tools > Edit Xref in Place
- Tools > New UCS > World
@@ -101,14 +92,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Tools > New UCS > Z
- Tools > Named UCS...
- Tools > Customize > Interface (CUI)...
- Tools > Customize > Edit Command Aliases (PGP)...
- Tools > Import Settings
- Tools > Export Settings
- Tools > Express Tools > Convert Text to Mtext
- Tools > Express Tools > Break-line Symbol
- Tools > Express Tools > Move/Copy/Rotate
- Tools > Express Tools > Explode Attributes to Text
- Tools > Express Tools > Replace Block...
- Tools > Express Tools > Super Hatch > Image...
- Tools > Express Tools > Super Hatch > Xref...
- Tools > Express Tools > Super Hatch > Select Existing Object
+3 -2
View File
@@ -1,6 +1,6 @@
# Target-app parity: CADCraft vs AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (RECTANG centre construction mode, #1127; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work
remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall,
@@ -156,7 +156,7 @@ presence: an option that prints "not available yet" doesn't count.
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) |
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) |
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) |
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) |
| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) |
| Collaboration and cloud (Sheet Set Manager, Share, Traces, Markup Import, Activity Insights, DWG Compare) | 2% | 5% | 40–70 | none of these exist | [gaps](gaps.md#features) |
@@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured |
| 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured |
| 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |
| 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured |