Compare commits

..
Author SHA1 Message Date
aiongg b4a8773c3c Merge pull request #2193 from dotCooCoo/feat/measure-arc-segments
AREA and DIST: the Arc option measures arc segments
2026-10-27 13:44:25 -04:00
aiongg 180c1885bb Merge pull request #2177 from dotCooCoo/feat/layout-saveas-template
LAYOUT: SAveas saves a layout as a drawing template
2026-10-27 13:44:03 -04:00
aiongg 9d32ead9e6 Merge pull request #2175 from dotCooCoo/feat/named-viewports
View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
2026-10-27 13:43:43 -04:00
aiongg 2c73e5f063 Merge pull request #2162 from dotCooCoo/fix/area-too-large
AREA: an area, perimeter or running total past f64's range is refused
2026-10-27 13:43:23 -04:00
aiongg ec762af206 Merge pull request #2113 from dotCooCoo/feat/block-description
Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
2026-10-27 13:43:02 -04:00
aiongg 786367e9ba Merge pull request #2331 from Silvertree2010/perf/export-speed
Speed up PNG export: stroke runs of hairline polylines as one path
2026-10-27 13:41:15 -04:00
NetterSilverandClaude Opus 5.5 850dd94f3c Stroke runs of hairline polylines as one path in the rasteriser
PNG export (and everything else drawn by raster::render) stroked every
polyline primitive with its own tiny-skia stroke_path call: a new path
and a newly built paint pipeline per primitive. Hatch patterns, linetype
dashes and text strokes produce long runs of short polylines in one
colour, so that per-call setup dominated the render.

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

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

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

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

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

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

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

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:26:40 -04:00
aiongg d864a08b01 Merge pull request #2220 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:30:19 -04:00
skylar taylor-barrick c3b8e7b7fe Merge pull request #2227 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:13 -06:00
skylar b06fd039fb ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:04 -06:00
dotCooCoo 98a305514b AREA and DIST: the Arc option measures arc segments
AREA's next-point prompt and DIST's Multiple points prompt offered Arc,
and MEASUREGEOM's Distance, Area and Volume options ask the same
prompts, but Arc printed "Arc: not available yet". Arc now turns on arc
mode with PLINE's arc options (Angle, CEnter, CLose, Direction, Line,
Radius, Second pt, Undo; CEnter takes an end point, Angle or chord
Length), and the arcs count in the distance, area, perimeter and
volume. A point in arc mode ends an arc tangent to the
last segment, Length after an arc continues along its end tangent, and
Undo removes the last arc. CLose ends with an arc back to the first
point. A point straight behind the last segment is refused with
"Invalid arc endpoint.".

In a DIST measured through one viewport, an arc's computed end, a typed
radius and a typed chord length are in the viewport's model units, as
Length's distance is.
2026-10-25 21:17:18 -07:00
dotCooCoo 605dc43898 Merge branch 'main' into feat/named-viewports 2026-10-25 21:10:43 -07:00
dotCooCoo c22ad3229f LAYOUT: SAveas saves a layout as a drawing template
LAYOUT SAveas printed "LAYOUT SAveas is not available yet." It now asks
for the layout (the current one by default) and the template file
(<layout>.dwt beside the drawing by default), and writes the layout as a
template: its page setup and paper space objects, viewports included, no
model space objects and no other layout. Named objects nothing in it uses
are left out, as PURGE All removes them. Layers the layout's viewports
freeze or recolor are kept, with those settings. The open drawing is not
changed.

The JSON form is layout.saveas {name?, path}, also reached as LAYOUT
{option: saveas}. A path without an extension gets .dwt. The command
also writes .dwg and .dxf paths and refuses other file types.
2026-10-25 17:07:28 -07:00
dotCooCoo 7d0d475aee View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations
-VPORTS saved, restored and deleted named model space viewport
configurations, and MVIEW Restore placed one in a layout, but View >
Viewports > Named Viewports... had no command. The new command
vports.named runs from the menu item. Typed or from the menu it opens
the Named Viewports dialog: the saved configurations, a preview of the
selected one's tiles, and in the Model tab the name of the configuration
whose tiles are where the Model tab's are, else *Active. Restore shows
the configuration in the Model tab, and in a layout it starts MVIEW
Restore with the name, which asks for the corners. Delete deletes the
saved configuration. The JSON form takes {name?, delete?, p1?, p2?,
layout?}, and without a name it returns the configurations and the
current one.

Catalog rows for the new menu label are added in all 15 languages. In
docs/gaps.md, U11 is narrowed, and F15, U11 and F21 no longer list MVIEW
Restore (#633) or PURGE of multiline styles, which are on main.
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-25 16:07:11 -07:00
dotCooCoo 48c4a5e9d5 AREA: an area, perimeter or running total past f64's range is refused
AREA reported an area or perimeter past f64's range as "Area = 0" (the
JSON form returned `"area": null`), and Add area or Subtract area could
push the running total past it, after which every total read
"Total area = 0". In MEASUREGEOM's Volume option, such an area waited
for a height that could never give a volume, so every later area in
Add or Subtract mode waited behind it.

`area` now returns "the area is too large" or "the perimeter is too
large". Typed, AREA echoes the same message instead of the result, and
an area that would carry the total past f64's range echoes "The total
area is too large." and leaves the total as it was. The Volume option
echoes "The area is too large." and does not ask for a height for that
area.
2026-10-25 13:19:12 -07:00
dotCooCoo 57f828d04c Blocks: Modify > Object > Block Description..., and the Block Editor saves a changed description
No command changed an existing block's description; it could only be
given when the block was made or redefined (BLOCK, -BLOCK, entmake).
blocks.description (Modify > Object > Block Description...) changes
it: the block is the one the Block Editor edits, else the selected
reference's, else a name typed or a reference picked. In the Block
Editor, Properties with nothing selected shows the block's name and an
editable Description; BSAVE saves the new description with the block,
BCLOSE without saving drops it, and BSAVEAS gives it to the new block
only. A description is one line of up to 255 characters, and BLOCK and
-BLOCK now refuse one that is longer or has a line break.

Catalog rows for the new menu label are added in all 15 languages, and
docs/parity-checklist.md is regenerated with cargo xtask parity.
2026-10-24 21:11:10 -07:00
59 changed files with 2294 additions and 153 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
Generated
+223 -24
View File
@@ -872,6 +872,15 @@ dependencies = [
"error-code",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -995,12 +1004,40 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fixed_decimal",
"icu_decimal",
"icu_locale_core",
"icu_locale_fallback",
"icu_normalizer",
"icu_plurals",
"icu_properties",
"icu_provider",
"icu_provider_adapters",
"icu_provider_blob",
"log",
"objc2-foundation 0.3.2",
"sys-locale",
"unicode-bidi",
"windows-registry",
"writeable",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-syntax",
"krilla",
@@ -1010,20 +1047,28 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-fonts-build",
"craft-i18n",
"egui",
"epaint",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
"skrifa 0.44.0",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"chrono",
"dunce",
@@ -1048,7 +1093,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -1555,6 +1600,17 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fixed_decimal"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
dependencies = [
"displaydoc",
"smallvec",
"writeable",
]
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -1995,7 +2051,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-ccitt",
]
@@ -2009,7 +2065,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
@@ -2074,9 +2130,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "icu_collections"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@@ -2087,43 +2143,110 @@ dependencies = [
]
[[package]]
name = "icu_locale_core"
version = "2.4.0"
name = "icu_decimal"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_locale_core",
"icu_pattern",
"icu_plurals",
"icu_provider",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.4.0"
name = "icu_locale_fallback"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_pattern"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
dependencies = [
"displaydoc",
"either",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_plurals"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_provider",
"serde",
"zerovec",
]
[[package]]
name = "icu_properties"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
@@ -2136,18 +2259,21 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.4.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"postcard",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2155,6 +2281,30 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_provider_adapters"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
dependencies = [
"icu_locale_fallback",
"icu_provider",
]
[[package]]
name = "icu_provider_blob"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
dependencies = [
"icu_provider",
"postcard",
"serde",
"writeable",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
@@ -2371,7 +2521,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2469,6 +2619,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
dependencies = [
"serde_core",
]
[[package]]
name = "litrs"
@@ -3400,12 +3553,24 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -4220,7 +4385,10 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
]
[[package]]
@@ -4372,6 +4540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -4614,6 +4783,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -5222,6 +5397,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -5462,11 +5648,20 @@ dependencies = [
"read-fonts 0.39.2",
]
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
dependencies = [
"either",
]
[[package]]
name = "x11-dl"
@@ -5728,8 +5923,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"litemap",
"serde_core",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
@@ -5738,6 +5936,7 @@ version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+9 -11
View File
@@ -25,10 +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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false }
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
# foundation
thiserror = "2"
@@ -53,15 +53,15 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -69,11 +69,9 @@ arboard = { version = "3.6", default-features = false }
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
@@ -93,9 +91,9 @@ debug = 0
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
# (Cargo ignores dependency-local patches).
[patch.crates-io]
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Keep the complete egui family and native event bridge on reviewed fork commits.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+3 -1
View File
@@ -2,4 +2,6 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
cognitive-complexity-threshold = 30
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
+38
View File
@@ -144,6 +144,11 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
d.groups = host.groups.clone();
@@ -171,6 +176,21 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
}
impl DocState {
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
pub fn drawing(&self) -> &Arc<Drawing> {
@@ -272,6 +292,24 @@ impl DocState {
let d = be.compose(&self.doc, &name)?;
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
// When BSAVEAS saves the block under another name, the editor's drawing and each undo and
// redo step get the block as saved and the edited block as the drawing has it, so a later
// save and the description shown start from them after an undo too.
if !name.eq_ignore_ascii_case(&be.name)
&& let Some(b) = d.block(&name).cloned()
{
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
reconcile(&mut self.doc);
for step in self.undo.iter_mut().chain(self.redo.iter_mut()) {
reconcile(&mut step.doc);
}
}
let editor = self.doc.clone();
let Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone();
+7 -3
View File
@@ -19,6 +19,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
mod attedit;
mod burst;
mod description;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
@@ -28,12 +29,12 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("block", "Make...", run_block)
.menu(&["Draw", "Block", "Make..."])
.alias(&["b", "bmake"])
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}")
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description? (up to 255 characters, one line), annotative?}")
.interactive(|_| Ok(Box::new(BlockM::default()))),
// -BLOCK erases the selected objects once the block is defined.
CommandSpec::new("-block", "Create Block", run_dash_block)
.alias(&["-b"])
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}")
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description? (up to 255 characters, one line)}")
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
CommandSpec::new("insert", "Block...", run_insert)
.menu(&["Insert", "Block..."])
@@ -79,6 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
blockreplace::blockreplace_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
description::description_spec(),
]
.into_iter()
.chain(attribs::attdisp_menu_specs())
@@ -266,7 +268,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
let hs = targets(s, p)?;
let keep = keep_param("block", p, Keep::Convert)?;
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
let description = str_param(p, "description").unwrap_or("");
description::check_text("block", description)?;
let r = make_block(s, &name, base, &hs, keep, description)?;
// An annotative block: its references (the converted selection too) are annotative.
if bool_or(p, "annotative", false) {
let d = s.doc_mut()?;
+2 -2
View File
@@ -85,7 +85,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
}
/// The block a block reference refers to.
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
pub(super) fn block_of(d: &Drawing, h: Handle) -> Option<String> {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
_ => None,
@@ -129,7 +129,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
}
/// The one block reference selected, if exactly one is.
fn selected_reference(s: &Session) -> Option<String> {
pub(super) fn selected_reference(s: &Session) -> Option<String> {
let d = s.doc().ok()?;
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
match refs.as_slice() {
+188
View File
@@ -0,0 +1,188 @@
//! Block Description (Modify > Object > Block Description...): changes the description of a
//! block definition. In the Block Editor it changes the edited block's description, which BSAVE
//! saves into the drawing as it saves the block's objects.
use std::sync::Arc;
use cadcraft_doc::Block;
use serde_json::{Value, json};
use super::bedit::selected_reference;
use crate::cmd::{CommandSpec, bad, has_doc, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The longest description a block takes, in characters.
const MAX_LEN: usize = 255;
pub(super) fn description_spec() -> CommandSpec {
CommandSpec::new("blocks.description", "Block Description...", run)
.menu(&["Modify", "Object", "Block Description..."])
.params("{name? (default the block the Block Editor is editing, else the one selected block reference's block), description (up to 255 characters, one line; \"\" clears it)} → {block, description}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(DescriptionM::default())))
}
/// The block the Block Editor is editing, when it is open on a block (not on the drawing itself).
fn edited_block(s: &Session) -> Option<String> {
s.state().ok()?.block_editor().filter(|n| !n.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING)).map(str::to_string)
}
/// The block that a call without a name is about: the edited block, else the selected reference's.
fn default_block(s: &Session) -> Option<String> {
edited_block(s).or_else(|| selected_reference(s))
}
/// Block `name` as the drawing spells it, when its description can be changed: while the Block
/// Editor edits a block, only that block's. The block the editor has started and not saved yet has
/// no definition, and is named as the editor names it.
fn check_block(s: &Session, name: &str) -> Result<String> {
let name = name.trim();
if let Some(edited) = edited_block(s)
&& !edited.eq_ignore_ascii_case(name)
{
return Err(bad(
"blocks.description",
format!("The Block Editor is editing \"{edited}\"; close it (BCLOSE) to change another block's description."),
));
}
let d = s.doc()?;
match d.block(name) {
Some(b) if b.xref_path.is_some() => {
Err(bad("blocks.description", format!("\"{}\" is an external reference; its description comes from its drawing.", b.name)))
}
Some(b) if b.anonymous || b.name.starts_with('*') => {
Err(bad("blocks.description", format!("\"{}\" is an anonymous block made by another object; it has no description to change.", b.name)))
}
Some(b) => Ok(b.name.clone()),
None => match edited_block(s) {
Some(e) if e.eq_ignore_ascii_case(name) => Ok(e),
_ => Err(bad("blocks.description", format!("Block \"{name}\" not found."))),
},
}
}
/// A description a block can take (command `cmd`'s): one line of up to [`MAX_LEN`] characters.
pub(super) fn check_text(cmd: &str, text: &str) -> Result<()> {
if text.contains(['\n', '\r']) {
return Err(bad(cmd, "the description must be one line"));
}
if text.chars().count() > MAX_LEN {
return Err(bad(cmd, format!("the description can be up to {MAX_LEN} characters")));
}
Ok(())
}
/// Block `name`'s definition: the drawing's, else, for the block the Block Editor edits, the one
/// outside the editor (a block saved under a new name with BSAVEAS is not in the editor's tables).
fn definition(s: &Session, name: &str) -> Option<Arc<Block>> {
let st = s.state().ok()?;
st.doc.block(name).or_else(|| st.block_edit.as_ref().and_then(|be| be.host.doc.block(name))).cloned()
}
/// The description of block `name` (as [`check_block`] gave it), or "" for one not yet saved.
fn current(s: &Session, name: &str) -> String {
definition(s, name).map(|b| b.description.clone()).unwrap_or_default()
}
/// Give block `name` (as [`check_block`] gave it) the description `text`.
fn set(s: &mut Session, name: &str, text: &str) -> Result<Value> {
check_text("blocks.description", text)?;
let outside = definition(s, name);
let doc = s.doc_mut()?;
match doc.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) {
Some((_, b)) => {
if b.description != text {
Arc::make_mut(b).description = text.to_string();
}
}
// The block the Block Editor edits and the editor's tables don't hold (one it started, or
// one BSAVEAS made): BSAVE saves it with this description.
None => {
let mut b = outside.map(|b| (*b).clone()).unwrap_or_else(|| Block::new(name));
b.description = text.to_string();
doc.blocks.insert(name.to_string(), Arc::new(b));
}
}
if s.state()?.block_editor().is_some() {
s.echo(format!("Description of block \"{name}\" changed; BSAVE saves it into the drawing."));
} else {
s.echo(format!("Description of block \"{name}\" changed."));
}
Ok(json!({ "block": name, "description": text }))
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => default_block(s)
.ok_or_else(|| bad("blocks.description", "`name` is required (or open the block in the Block Editor, or select one block reference)"))?,
};
let text = str_param(p, "description").ok_or_else(|| bad("blocks.description", "`description` is required"))?;
let name = check_block(s, &name)?;
set(s, &name, text)
}
// ---------------- interactive ----------------
/// Block Description: the block (the edited one, the selected reference's, or a name typed or a
/// reference picked), then its new description; Enter keeps the description it has.
#[derive(Default)]
struct DescriptionM {
/// The block whose description is asked for, once known.
block: Option<String>,
}
impl Interactive for DescriptionM {
fn name(&self) -> &'static str {
"BLOCKS.DESCRIPTION"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if let Some(name) = default_block(s) {
self.block = Some(check_block(s, &name)?);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match &self.block {
Some(name) => {
let cur = current(s, name);
let p = Prompt::new(format!("Enter description for block \"{name}\""), Accept::TEXT);
if cur.is_empty() { p } else { p.default(cur) }
}
None => Prompt::new(
"Enter block name or select a block reference",
Accept { point: true, number: false, text: true, select: true, enter: true },
),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(name) = self.block.clone() {
return match i {
Input::Text(t) => set(s, &name, t.trim()).map(|_| Step::Done),
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
};
}
match i {
Input::Text(t) | Input::Keyword(t) => {
self.block = Some(check_block(s, &t)?);
Ok(Step::Continue)
}
Input::Pick(hs) => {
let picked = {
let d = s.doc()?;
hs.iter().find_map(|h| super::bedit::block_of(d, *h))
};
match picked {
Some(n) => {
self.block = Some(check_block(s, &n)?);
Ok(Step::Continue)
}
None => Err(EngineError::Other("Select a block reference.".into())),
}
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
+140 -47
View File
@@ -11,6 +11,7 @@ use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_p
use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod arcs;
mod loops;
mod regions;
mod shapes;
@@ -418,6 +419,9 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
object_area(s, h)?
};
if let Some(what) = too_large(a, per) {
return Err(bad("area", format!("the {what} is too large")));
}
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
}
@@ -456,6 +460,17 @@ fn fmt_volume(s: &Session, v: f64) -> String {
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
}
/// "area" or "perimeter" when that value is past f64's range, `None` when both can be reported.
fn too_large(a: f64, per: f64) -> Option<&'static str> {
if !a.is_finite() {
Some("area")
} else if !per.is_finite() {
Some("perimeter")
} else {
None
}
}
/// 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()?;
@@ -694,10 +709,16 @@ pub(super) struct DistM {
sources: Vec<Option<crate::snap::ViewportPoint>>,
/// Asking for the length of a segment (the Length option).
length: bool,
/// The bulge of the segment that starts at each point (the Arc option).
bulges: Vec<f64>,
arcs: arcs::Arcs,
}
/// Total length of the segments through `pts`.
fn path_len(pts: &[Vec2]) -> f64 {
/// Total length of the segments through `pts`, each with its bulge in `bulges`.
fn path_len(pts: &[Vec2], bulges: &[f64]) -> f64 {
if arcs::has_arcs(bulges) {
return arcs::polyline(pts, bulges, false).len();
}
pts.windows(2)
.map(|w| match w {
[a, b] => a.dist(*b),
@@ -707,15 +728,11 @@ fn path_len(pts: &[Vec2]) -> f64 {
}
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
/// point): the Length option of AREA and DIST's point sequences.
fn extend_last(pts: &[Vec2], len: f64) -> Option<Vec2> {
/// point, along the tangent after an arc segment): the Length option of AREA and DIST's point
/// sequences.
fn extend_last(pts: &[Vec2], bulges: &[f64], len: f64) -> Option<Vec2> {
let last = *pts.last()?;
let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) {
Some(prev) => (last - *prev).normalized(),
None => Vec2::X,
};
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
let p = last + dir * len;
let p = last + arcs::tangent(pts, bulges) * len;
p.is_finite().then_some(p)
}
@@ -724,13 +741,21 @@ fn length_prompt() -> Prompt {
}
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
fn length_input(pts: &[Vec2], bulges: &[f64], i: Input) -> Result<Option<Vec2>> {
let t = match i {
Input::Text(t) => t,
_ => return Ok(None),
};
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
Ok(extend_last(pts, len))
Ok(extend_last(pts, bulges, len))
}
/// Where the paper point `p`, computed near `last` (an arc option's end point), lies in the model
/// space of the viewport `last` was picked through (the last of `sources`).
fn follow_source(sources: &[Option<crate::snap::ViewportPoint>], last: Vec2, p: Vec2) -> Option<crate::snap::ViewportPoint> {
let source = sources.last().copied().flatten()?;
let point = source.point + (p - last) / source.scale;
(source.scale.abs() > 1e-12 && point.is_finite()).then_some(crate::snap::ViewportPoint { point, ..source })
}
/// Model units apply only when every point was snapped through one and the same viewport.
@@ -743,10 +768,13 @@ impl Interactive for DistM {
fn name(&self) -> &'static str {
"DIST"
}
fn prompt(&self, _s: &Session) -> Prompt {
fn prompt(&self, s: &Session) -> Prompt {
if self.length {
return length_prompt();
}
if let Some(p) = self.multi.as_deref().and_then(|pts| self.arcs.prompt(s, pts, &self.bulges, "")) {
return p;
}
match (&self.multi, self.first) {
(Some(pts), _) if pts.len() < 2 => {
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
@@ -763,7 +791,7 @@ impl Interactive for DistM {
if let Some(pts) = &mut self.multi {
if self.length {
let model = model_points(&self.sources);
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? {
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), &self.bulges, i)? {
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
let paper = match (source, pts.last()) {
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
@@ -772,31 +800,64 @@ impl Interactive for DistM {
if !paper.is_finite() {
return Err(bad("dist", "the next point is too large"));
}
pts.push(paper);
arcs::push(pts, &mut self.bulges, paper, 0.0);
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
}
self.length = false;
return Ok(Step::Continue);
}
if self.arcs.asking() {
let last = pts.last().copied();
// A picked end point keeps its own viewport; an end computed from the answers follows the
// last point's. In model units, a typed radius or chord length is in model units too.
let picked_end = match i {
Input::Point(p) if self.arcs.asks_end_point() => Some(p),
_ => None,
};
let i = match model_points(&self.sources).and(self.sources.last().copied().flatten()) {
Some(source) => self.arcs.scale_typed_size(i, source.scale),
None => i,
};
if self.arcs.answer(s, i, pts, &mut self.bulges)? {
// The arc can end off the picked point (CEnter projects it onto the circle).
let source = match (picked_end, last, pts.last()) {
(Some(p), _, Some(end)) => follow_source(&[s.picked_viewport], p, *end),
(None, Some(last), Some(end)) => follow_source(&self.sources, last, *end),
_ => None,
};
self.sources.push(source);
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
}
return Ok(Step::Continue);
}
match i {
Input::Point(p) => {
pts.push(p);
self.arcs.point(pts, &mut self.bulges, p)?;
self.sources.push(s.picked_viewport);
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
}
Input::Keyword(k) if k == "Undo" => {
if pts.len() > 1 {
pts.pop();
self.arcs.undo(pts, &mut self.bulges);
self.sources.pop();
}
}
Input::Keyword(k) if k == "Length" => self.length = true,
Input::Keyword(k) if k == "Total" => return self.total(s),
Input::Keyword(k) => s.echo(format!("{k}: not available yet")),
Input::Enter => return self.total(s),
Input::Keyword(k) if k == "Total" => return self.total(s, false),
Input::Keyword(k) if k == "CLose" && self.arcs.on => {
self.arcs.close(pts, &mut self.bulges)?;
return self.total(s, true);
}
Input::Keyword(k) => {
if !self.arcs.keyword(&k, pts) {
s.echo(format!("{k}: not available yet"));
}
}
Input::Enter => return self.total(s, false),
_ => {}
}
return Ok(Step::Continue);
@@ -830,20 +891,20 @@ impl Interactive for DistM {
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match &self.multi {
Some(pts) => {
let mut v: Vec<cadcraft_geom::PolyVertex> = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
v.push(cadcraft_geom::PolyVertex::new(c));
vec![super::helpers::lwpoly(v, false)]
}
Some(pts) => self.arcs.preview(pts, &self.bulges, c, false),
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
}
}
}
impl DistM {
fn total(&self, s: &mut Session) -> Result<Step> {
/// Report the total distance and end; `closed` adds the closing segment (CLose).
fn total(&self, s: &mut Session, closed: bool) -> Result<Step> {
let model = model_points(&self.sources);
let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len);
let d = model
.as_deref()
.or(self.multi.as_deref())
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
s.echo(format!("Distance = {}", fmt_d(s, d)));
Ok(Step::Done)
}
@@ -936,6 +997,9 @@ pub(super) struct AreaM {
pending: Vec<f64>,
/// The first point of a height given by two points.
height_from: Option<Vec2>,
/// The bulge of the segment that starts at each point (the Arc option).
bulges: Vec<f64>,
arcs: arcs::Arcs,
}
impl AreaM {
@@ -1039,27 +1103,45 @@ impl AreaM {
}
/// 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.
/// An area, perimeter or total past f64's range is not reported or added to the total.
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
if self.volume {
if !a.is_finite() {
s.echo("The area is too large.");
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
self.pending.push(a);
return Step::Continue;
}
s.echo(area_text(s, a, per));
match self.mode {
AreaMode::Single => return Step::Done,
AreaMode::Add => self.total += a,
AreaMode::Subtract => self.total -= a,
let total = self.total_with(a);
if let Some(what) = too_large(a, per).or_else(|| (!total.is_finite()).then_some("total area")) {
s.echo(format!("The {what} is too large."));
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
s.echo(area_text(s, a, per));
if self.mode == AreaMode::Single {
return Step::Done;
}
self.total = total;
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
Step::Continue
}
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
/// An arc segment (the Arc option) can close an area of two points.
fn close(&mut self, s: &mut Session) -> Step {
let pts = std::mem::take(&mut self.pts);
if pts.len() < 3 {
let bulges = std::mem::take(&mut self.bulges);
self.arcs = arcs::Arcs::default();
let curved = arcs::has_arcs(&bulges);
if pts.len() < if curved { 2 } else { 3 } {
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
let (a, per) = area_of(&pts);
let (a, per) = if curved {
let g = arcs::polyline(&pts, &bulges, true);
(g.area().abs(), g.len())
} else {
area_of(&pts)
};
self.measured(s, a, per)
}
}
@@ -1068,7 +1150,7 @@ impl Interactive for AreaM {
fn name(&self) -> &'static str {
if self.volume { "MEASUREGEOM" } else { "AREA" }
}
fn prompt(&self, _s: &Session) -> Prompt {
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),
@@ -1081,6 +1163,9 @@ impl Interactive for AreaM {
if self.length {
return length_prompt();
}
if let Some(p) = self.arcs.prompt(s, &self.pts, &self.bulges, self.mode_prefix()) {
return p;
}
let next = format!("{}Specify next point", self.mode_prefix());
let (add, subtract) = self.mode_keywords();
match (self.pts.len(), self.mode) {
@@ -1099,24 +1184,34 @@ impl Interactive for AreaM {
return Ok(self.object_input(s, i));
}
if self.length {
if let Some(p) = length_input(&self.pts, i)? {
self.pts.push(p);
if let Some(p) = length_input(&self.pts, &self.bulges, i)? {
arcs::push(&mut self.pts, &mut self.bulges, p, 0.0);
}
self.length = false;
return Ok(Step::Continue);
}
if self.arcs.asking() {
self.arcs.answer(s, i, &mut self.pts, &mut self.bulges)?;
return Ok(Step::Continue);
}
match i {
Input::Point(p) => self.pts.push(p),
Input::Point(p) => self.arcs.point(&mut self.pts, &mut self.bulges, p)?,
Input::Keyword(k) => match k.as_str() {
"Object" => self.object = true,
"Add area" | "Add volume" => self.mode = AreaMode::Add,
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
"Undo" => {
self.pts.pop();
}
"Undo" => self.arcs.undo(&mut self.pts, &mut self.bulges),
"Length" => self.length = true,
"Total" => return Ok(self.close(s)),
_ => s.echo(format!("{k}: not available yet")),
"CLose" if self.arcs.on => {
self.arcs.close(&self.pts, &mut self.bulges)?;
return Ok(self.close(s));
}
k => {
if !self.arcs.keyword(k, &self.pts) {
s.echo(format!("{k}: not available yet"));
}
}
},
Input::Enter if self.pts.is_empty() => match self.mode {
// Enter takes the default, Object; in add/subtract mode it ends the command.
@@ -1132,9 +1227,7 @@ impl Interactive for AreaM {
if self.pts.is_empty() || self.object || self.length {
return Vec::new();
}
let mut v: Vec<cadcraft_geom::PolyVertex> = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
v.push(cadcraft_geom::PolyVertex::new(c));
vec![super::helpers::lwpoly(v, true)]
self.arcs.preview(&self.pts, &self.bulges, c, true)
}
}
+195
View File
@@ -0,0 +1,195 @@
//! The Arc option of AREA's and DIST's point sequences (and of MEASUREGEOM's Distance, Area and
//! Volume): arc segments, asked for as PLINE's arc mode asks for them. Each point carries the
//! bulge of the segment that starts at it; the last point's bulge is the closing segment's,
//! which only CLose sets.
use cadcraft_doc::EntityKind;
use cadcraft_geom::{PolyVertex, Polyline, Vec2, arc_to_bulge, bulge_to_arc};
use super::super::helpers::lwpoly;
use super::super::machines::number;
use super::super::pline_opts::{Outcome, PlineAsk};
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
/// Arc mode's keywords: PLINE's arc mode without Halfwidth and Width.
const KEYWORDS: [&str; 8] = ["Angle", "CEnter", "CLose", "Direction", "Line", "Radius", "Second pt", "Undo"];
/// Arc mode of a point sequence.
#[derive(Default)]
pub(super) struct Arcs {
/// The Arc option turned arc mode on; Line turns it off.
pub(super) on: bool,
/// An arc option waiting for its answers (Angle, CEnter, Direction, Radius, Second pt).
ask: Option<PlineAsk>,
}
/// The polyline through `pts`, each segment with its bulge in `bulges` (straight when missing).
pub(super) fn polyline(pts: &[Vec2], bulges: &[f64], closed: bool) -> Polyline {
let vertices = pts.iter().enumerate().map(|(i, p)| PolyVertex::with_bulge(*p, bulges.get(i).copied().unwrap_or(0.0))).collect();
Polyline { vertices, closed }
}
/// Whether any segment of the sequence is an arc.
pub(super) fn has_arcs(bulges: &[f64]) -> bool {
bulges.iter().any(|b| *b != 0.0)
}
/// The direction the sequence runs in at its last point (+X before the second point): along the
/// last line segment, or along the tangent at the end of the last arc segment.
pub(super) fn tangent(pts: &[Vec2], bulges: &[f64]) -> Vec2 {
let Some(i) = pts.len().checked_sub(2) else { return Vec2::X };
let (Some(prev), Some(last)) = (pts.get(i).copied(), pts.last().copied()) else { return Vec2::X };
let d = match bulge_to_arc(prev, last, bulges.get(i).copied().unwrap_or(0.0)) {
Some((a, ccw)) => {
let r = (last - a.center).perp().normalized();
if ccw { r } else { -r }
}
None => (last - prev).normalized(),
};
if d == Vec2::ZERO { Vec2::X } else { d }
}
/// The bulge of an arc from the last point to `p` that continues the sequence's direction; an
/// error when `p` lies straight behind the last point, where no such arc exists.
fn tangent_bulge(pts: &[Vec2], bulges: &[f64], p: Vec2) -> Result<f64> {
let Some(last) = pts.last().copied() else { return Ok(0.0) };
let dir = tangent(pts, bulges);
let chord = p - last;
// The included angle is twice the angle between the tangent and the chord.
let theta = dir.cross(chord).atan2(dir.dot(chord));
let b = arc_to_bulge(2.0 * theta);
if theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite() { Ok(b) } else { Err(EngineError::Other("Invalid arc endpoint.".into())) }
}
/// Add the segment from the last point to `end` with bulge `bulge` (the first point when there is none).
pub(super) fn push(pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, end: Vec2, bulge: f64) {
bulges.resize(pts.len(), 0.0);
if let Some(b) = bulges.last_mut() {
*b = bulge;
}
pts.push(end);
bulges.push(0.0);
}
impl Arcs {
/// An arc option is waiting for its answers.
pub(super) fn asking(&self) -> bool {
self.ask.is_some()
}
/// The question being answered takes the arc's end point, so a picked point is the end of the segment.
pub(super) fn asks_end_point(&self) -> bool {
matches!(
self.ask,
Some(PlineAsk::AngleEnd(_) | PlineAsk::CenterEnd(_) | PlineAsk::DirectionEnd(_) | PlineAsk::RadiusEnd(_) | PlineAsk::SecondEnd(_))
)
}
/// A typed radius or chord length multiplied by `scale`, for a sequence measured in another
/// space's units than the one its points are in; any other input as it is.
pub(super) fn scale_typed_size(&self, i: Input, scale: f64) -> Input {
let size = matches!(self.ask, Some(PlineAsk::Radius | PlineAsk::AngleRadius(_) | PlineAsk::CenterLength(_)));
match i {
Input::Text(t) if size => match number(&t).map(|v| v * scale).filter(|v| v.is_finite()) {
Some(v) => Input::Text(v.to_string()),
None => Input::Text(t),
},
i => i,
}
}
/// The arc-mode prompt, or the question of the arc option being answered; `None` in line mode
/// and before the first point.
pub(super) fn prompt(&self, s: &Session, pts: &[Vec2], bulges: &[f64], prefix: &str) -> Option<Prompt> {
let st = pts.last().copied()?;
if let Some(a) = self.ask {
return Some(a.prompt(st, tangent(pts, bulges), s));
}
self.on.then(|| Prompt::new(format!("{prefix}Specify endpoint of arc"), Accept::POINT).kw(&KEYWORDS).base(st))
}
/// Feed an answer to the arc option being answered; true when it added a segment.
pub(super) fn answer(&mut self, s: &Session, i: Input, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) -> Result<bool> {
let (Some(a), Some(st)) = (self.ask, pts.last().copied()) else {
self.ask = None;
return Ok(false);
};
match a.input(s, i, st, tangent(pts, bulges))? {
Outcome::Ask(next) => self.ask = Some(next),
Outcome::Back => self.ask = None,
Outcome::Segment(end, bulge) => {
self.ask = None;
push(pts, bulges, end, bulge);
return Ok(true);
}
}
Ok(false)
}
/// Arc, Line, and arc mode's Angle, CEnter, Direction, Radius and Second pt; false for any
/// other keyword.
pub(super) fn keyword(&mut self, k: &str, pts: &[Vec2]) -> bool {
match k {
"Arc" if !pts.is_empty() => self.on = true,
"Line" if self.on => self.on = false,
_ => match PlineAsk::from_keyword(k, true) {
Some(a) if self.on && !pts.is_empty() => self.ask = Some(a),
_ => return false,
},
}
true
}
/// A point at the next-point prompt: in arc mode, the end of an arc that continues the
/// sequence's direction; otherwise the end of a line segment.
pub(super) fn point(&self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, p: Vec2) -> Result<()> {
let bulge = if self.on && !pts.is_empty() { tangent_bulge(pts, bulges, p)? } else { 0.0 };
push(pts, bulges, p, bulge);
Ok(())
}
/// CLose: the closing segment becomes an arc from the last point to the first that continues
/// the sequence's direction.
pub(super) fn close(&self, pts: &[Vec2], bulges: &mut Vec<f64>) -> Result<()> {
let Some(first) = pts.first().copied().filter(|_| pts.len() >= 2) else {
return Err(EngineError::Other("CLose needs at least two points.".into()));
};
let bulge = tangent_bulge(pts, bulges, first)?;
bulges.resize(pts.len(), 0.0);
if let Some(b) = bulges.last_mut() {
*b = bulge;
}
Ok(())
}
/// Undo: remove the last point and the segment to it. Removing the first point leaves arc mode.
pub(super) fn undo(&mut self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) {
pts.pop();
bulges.truncate(pts.len());
if let Some(b) = bulges.last_mut() {
*b = 0.0;
}
if pts.is_empty() {
*self = Arcs::default();
}
}
/// The sequence with the segment to the cursor `c` that a click would add.
pub(super) fn preview(&self, pts: &[Vec2], bulges: &[f64], c: Vec2, closed: bool) -> Vec<EntityKind> {
let Some(st) = pts.last().copied() else { return Vec::new() };
let next = match self.ask {
Some(a) => a.preview(st, tangent(pts, bulges), c),
None if self.on => tangent_bulge(pts, bulges, c).ok().map(|b| (c, b)),
None => Some((c, 0.0)),
};
let mut g = polyline(pts, bulges, closed);
if let Some((end, bulge)) = next {
if let Some(v) = g.vertices.last_mut() {
v.bulge = bulge;
}
g.vertices.push(PolyVertex::new(end));
}
vec![lwpoly(g.vertices, closed)]
}
}
+10 -3
View File
@@ -1,4 +1,4 @@
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy),
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas),
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
//!
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("layout", "Layout", run_layout)
.alias(&["lo"])
.params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)")
.params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)")
.interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),
CommandSpec::new("layout.new", "New Layout", run_new)
.menu(&["Insert", "Layout", "New Layout"])
@@ -32,6 +32,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas)
.params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}")
.noundo(),
CommandSpec::new("mview", "Viewports", run_mview)
.alias(&["mv"])
.params(
@@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
))
}
/// LAYOUT SAveas: a layout written as a drawing template.
mod saveas;
/// `-LAYOUT`-style dispatcher.
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
@@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
(set.run)(s, p)
}
"saveas" | "sa" => saveas::run_saveas(s, p),
"list" | "?" => run_list(s, p),
_ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")),
_ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")),
}
}
+20 -2
View File
@@ -25,6 +25,8 @@ enum Stage {
RenameFrom,
RenameTo(String),
Set,
SaveasLayout,
SaveasFile(String),
List,
}
@@ -34,7 +36,7 @@ pub struct LayoutM {
}
/// The layout the name prompts default to: the current one, else the first tab.
fn current_layout(s: &Session) -> Option<String> {
pub(super) fn current_layout(s: &Session) -> Option<String> {
if let Space::Paper(n) = s.layout_space() {
return Some(n);
}
@@ -70,9 +72,10 @@ impl LayoutM {
"New" => Stage::New,
"Rename" => Stage::RenameFrom,
"Set" => Stage::Set,
"SAveas" => Stage::SaveasLayout,
"?" => Stage::List,
other => {
// Template, SAveas: report and end, leaving the next input to the command line.
// Template: report and end, leaving the next input to the command line.
s.echo(format!("LAYOUT {other} is not available yet."));
return Ok(Step::Done);
}
@@ -131,6 +134,8 @@ impl Interactive for LayoutM {
let tab = s.layout_space().tab_name().to_string();
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
}
Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)),
Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)),
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
}
}
@@ -199,6 +204,19 @@ impl Interactive for LayoutM {
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
Ok(Step::Done)
}
Stage::SaveasLayout => {
let n = need(name.or_else(|| current_layout(s)))?;
let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?;
self.stage = Stage::SaveasFile(n);
Ok(Step::Continue)
}
Stage::SaveasFile(layout) => {
let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout));
let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?;
let path = r.get("path").and_then(Value::as_str).unwrap_or_default();
s.echo(format!("Layout \"{layout}\" saved to template \"{path}\"."));
Ok(Step::Done)
}
Stage::List => {
Self::list(s, name.as_deref().unwrap_or("*"))?;
Ok(Step::Done)
+133
View File
@@ -0,0 +1,133 @@
//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`).
//!
//! The template holds the layout's page setup and paper space objects, viewports included, and
//! no model space objects and no other layout. Named objects the layout does not use (blocks,
//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups)
//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay.
//! The open drawing is not changed.
use std::collections::BTreeSet;
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point};
use cadcraft_geom::Vec3;
use serde_json::{Value, json};
use super::super::file::{drops_unknown, io, not_saved, not_saved_message};
use super::super::{bad, str_param};
use super::layout_name;
use super::prompts::current_layout;
use crate::{Result, Session};
/// File types a layout template is written as; a path without an extension gets `.dwt`.
const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"];
/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing.
const MAX_PURGE_PASSES: usize = 64;
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
pub(super) fn default_path(s: &Session, layout: &str) -> String {
let file = format!("{layout}.dwt");
match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
None => file,
}
}
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
fn template_path(path: &str) -> Result<String> {
let path = path.trim();
if path.is_empty() {
return Err(bad("layout.saveas", "`path` is empty"));
}
match std::path::Path::new(path).extension() {
None => Ok(format!("{path}.dwt")),
Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()),
Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")),
}
}
/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space,
/// and without the named objects nothing left in it uses.
fn template_of(d: &Drawing, layout: &str) -> Result<Drawing> {
let mut t = d.clone();
t.layouts.retain(|l| l.name == layout);
t.active_layout = Some(layout.to_string());
t.model = EntityStore::new();
t.hidden_objects.clear();
let gone: Vec<u32> = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect();
t.constraints.retain(|c| !gone.contains(&c.id));
// Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts
// only objects, so each such layer gets a point in model space while PURGE runs.
let named: BTreeSet<String> = t
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::<Vec<_>>()),
_ => None,
})
.flatten()
.collect();
for layer in named {
let handle = t.new_handle();
t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) });
}
// PURGE runs on a session of its own, so the open drawing keeps everything.
let mut scratch = Session::empty();
scratch.open_drawing(t, layout, None);
for _ in 0..MAX_PURGE_PASSES {
let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?;
let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
if !purged {
break;
}
}
let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?;
let mut t = std::sync::Arc::unwrap_or_clone(st.doc);
t.model = EntityStore::new();
Ok(t)
}
/// `layout.saveas {name?, path}`: write a layout as a drawing template.
pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?;
if s.doc()?.refedit.is_some() {
return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
}
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?,
};
// A file is the drawing outside the Block Editor.
let d = s.state()?.drawing();
let name = layout_name("layout.saveas", d, &name)?;
let t = template_of(d, &name)?;
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
store(&path, &bytes)?;
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
let lost = not_saved(&t);
if drops_unknown(&path)
&& let Some(msg) = not_saved_message(&lost)
{
s.echo(msg);
if let Some(o) = r.as_object_mut() {
o.insert("notSaved".into(), json!(lost));
}
}
Ok(r)
}
/// Write the template: natively to `path` through a temporary file; on the web as a download.
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
}
}
+91 -2
View File
@@ -6,11 +6,12 @@
//! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current
//! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The
//! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to
//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW.
//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. View > Viewports > Named Viewports...
//! restores or deletes a saved configuration.
use std::sync::Arc;
use cadcraft_doc::{Drawing, Space, VportConfig, VportTile};
use cadcraft_doc::{Drawing, EntityKind, Space, VportConfig, VportTile};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
@@ -37,6 +38,12 @@ pub fn specs() -> Vec<CommandSpec> {
)
.interactive(vports_factory),
CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(),
CommandSpec::new("vports.named", "Named Viewports...", run_named)
.menu(&["View", "Viewports", "Named Viewports..."])
.params(
"{name?: a saved viewport configuration, delete?: bool, p1?, p2?, layout?} (no name: → {current, configurations: [{name, viewports}]}; the Model tab shows the configuration's tiles, a layout gets one viewport per tile over p1-p2 or the printable area; typed or from the menu: the Named Viewports dialog)",
)
.interactive(named_factory),
]
}
@@ -388,7 +395,59 @@ fn run_activate(s: &mut Session, p: &Value) -> Result<Value> {
summary(s)
}
/// View > Viewports > Named Viewports...: without a name, the saved configurations and the one
/// the Model tab shows; with one, restore it (its tiles in the Model tab, MVIEW Restore in a
/// layout) or, with `delete`, delete it.
fn run_named(s: &mut Session, p: &Value) -> Result<Value> {
let delete = match p.get("delete") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad("vports.named", "`delete` must be true or false")),
};
let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else {
let configs: Vec<Value> =
s.doc()?.vports.iter().filter(|c| !c.is_active()).map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect();
return Ok(json!({ "current": s.viewport_configuration(), "configurations": configs }));
};
let saved = s
.doc()?
.vports
.iter()
.find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name))
.map(|c| c.name.clone())
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
if delete {
s.doc_mut()?.vports.retain(|c| c.is_active() || c.name != saved);
return Ok(json!({ "deleted": saved, "message": format!("Viewport configuration \"{saved}\" deleted.") }));
}
if s.layout_space() != Space::Model || p.get("layout").is_some() {
let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?;
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.remove("delete");
o.insert("option".into(), json!("restore"));
o.insert("name".into(), json!(saved));
}
return (mview.run)(s, &p);
}
run_vports(s, &json!({ "option": "restore", "name": saved }))
}
/// Whether two configurations have their tiles in the same places (in any order).
fn same_places(a: &[VportTile], b: &[VportTile]) -> bool {
let near = |p: Vec2, q: Vec2| (p.x - q.x).abs() < EPS && (p.y - q.y).abs() < EPS;
a.len() == b.len() && a.iter().all(|t| b.iter().any(|u| near(t.min, u.min) && near(t.max, u.max)))
}
impl Session {
/// The saved viewport configuration whose tiles are where the Model tab's tiles are now (the
/// first such one in the drawing), if any.
pub fn viewport_configuration(&self) -> Option<String> {
let st = self.state().ok()?;
let (tiles, _) = live(st, self.viewport_px);
st.doc.vports.iter().filter(|c| !c.is_active()).find(|c| same_places(&c.tiles, &tiles)).map(|c| c.name.clone())
}
/// The model space tiles to draw (empty: one view, or not in the Model tab).
pub fn model_tiles(&self) -> Vec<ModelTile> {
let Ok(st) = self.state() else { return Vec::new() };
@@ -598,3 +657,33 @@ impl VportsM {
}
}
}
/// Named Viewports... without the dialog: -VPORTS Restore in the Model tab, MVIEW Restore in a
/// layout.
fn named_factory(s: &Session) -> Result<Box<dyn Interactive>> {
Ok(Box::new(Restoring(vports_factory(s)?)))
}
/// -VPORTS or MVIEW started at its Restore option.
struct Restoring(Box<dyn Interactive>);
impl Interactive for Restoring {
fn name(&self) -> &'static str {
self.0.name()
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
match self.0.begin(s)? {
Step::Continue => self.0.input(s, Input::Keyword("Restore".into())),
other => Ok(other),
}
}
fn prompt(&self, s: &Session) -> Prompt {
self.0.prompt(s)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
self.0.input(s, i)
}
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
self.0.preview(s, cursor)
}
}
+211
View File
@@ -0,0 +1,211 @@
//! Block Description (blocks.description): a block's description changes in the drawing, and in
//! the Block Editor BSAVE saves the edited block's new description.
use cadcraft_engine::Session;
use serde_json::{Value, json};
/// A drawing with block "Door" (one line, described "Hinged door") and one reference to it, which
/// is returned.
fn door(s: &mut Session) -> Value {
door_with(s, false)
}
fn door_with(s: &mut Session, annotative: bool) -> Value {
let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone();
let p = json!({ "name": "Door", "base": [10, 10], "handles": [l], "description": "Hinged door", "annotative": annotative });
s.execute("block", &p).unwrap()["insert"].clone()
}
fn description(s: &Session, block: &str) -> Option<String> {
s.doc().unwrap().block(block).map(|b| b.description.clone())
}
/// The description of `block` in the drawing outside the Block Editor.
fn saved_description(s: &Session, block: &str) -> Option<String> {
let st = s.state().unwrap();
let d = st.block_edit.as_ref().map_or(&st.doc, |be| &be.host.doc);
d.block(block).map(|b| b.description.clone())
}
#[test]
fn the_description_of_a_block_changes_and_undo_puts_it_back() {
let mut s = Session::new();
door(&mut s);
let r = s.execute("blocks.description", &json!({ "name": "door", "description": "Sliding door" })).unwrap();
assert_eq!(r, json!({ "block": "Door", "description": "Sliding door" }));
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
s.execute("undo", &json!({})).unwrap();
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
// An empty description clears it; 255 characters are the most a description takes.
s.execute("blocks.description", &json!({ "name": "Door", "description": "" })).unwrap();
assert_eq!(description(&s, "Door").as_deref(), Some(""));
let longest = "é".repeat(255);
s.execute("blocks.description", &json!({ "name": "Door", "description": longest })).unwrap();
assert_eq!(description(&s, "Door"), Some(longest));
}
#[test]
fn what_block_description_refuses() {
let mut s = Session::new();
door(&mut s);
for p in [
json!({ "name": "Door", "description": "x".repeat(256) }),
json!({ "name": "Door", "description": "two\nlines" }),
json!({ "name": "Door", "description": "carriage\rreturn" }),
json!({ "name": "Window", "description": "x" }),
json!({ "name": "Door" }),
json!({ "name": "Door", "description": 7 }),
json!({ "description": "nothing selected" }),
] {
assert!(s.execute("blocks.description", &p).is_err(), "{p}");
}
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
}
#[test]
fn block_refuses_a_description_a_block_cannot_take() {
let mut s = Session::new();
for cmd in ["block", "-block"] {
for description in ["x".repeat(256), "two\nlines".into()] {
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
let p = json!({ "name": "B", "base": [0, 0], "handles": [l], "description": description });
assert!(s.execute(cmd, &p).is_err(), "{cmd} {description:?}");
assert!(s.doc().unwrap().block("B").is_none());
}
}
for (cmd, name) in [("block", "B"), ("-block", "C")] {
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
s.execute(cmd, &json!({ "name": name, "base": [0, 0], "handles": [l], "description": "x".repeat(255) })).unwrap();
assert_eq!(description(&s, name), Some("x".repeat(255)), "{cmd}");
}
}
#[test]
fn the_selected_reference_names_the_block_at_the_prompts() {
let mut s = Session::new();
let r = door(&mut s);
s.execute("select", &json!({ "handles": [r] })).unwrap();
s.cmdline("blocks.description").unwrap();
assert!(s.prompt_text().contains("Enter description for block \"Door\" <Hinged door>"), "{}", s.prompt_text());
// Enter keeps the description.
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
s.execute("select", &json!({ "handles": [] })).unwrap();
s.script("blocks.description\nDoor\nSliding door\n").unwrap();
assert!(s.running.is_none());
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
}
#[test]
fn the_block_editor_saves_the_new_description_with_the_block() {
let mut s = Session::new();
door(&mut s);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
assert!(s.state().unwrap().block_unsaved());
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
s.execute("bsave", &json!({})).unwrap();
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Sliding door"));
// Closing without saving leaves the drawing's description as it was.
s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door"));
}
#[test]
fn bsaveas_gives_the_new_description_to_the_new_block_only() {
let mut s = Session::new();
door_with(&mut s, true);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap();
assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door"));
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
// The editor now edits the new block; saving again leaves the old block's description alone
// and keeps the new block's other properties.
s.execute("blocks.description", &json!({ "description": "Sliding door, 900" })).unwrap();
s.execute("bclose", &json!({})).unwrap();
assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door, 900"));
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
assert!(s.doc().unwrap().block("Slider").unwrap().annotative);
}
#[test]
fn bsaveas_over_another_block_keeps_the_saved_description() {
let mut s = Session::new();
door(&mut s);
let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l], "description": "Casement" })).unwrap();
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap();
assert_eq!(saved_description(&s, "Window").as_deref(), Some("Sliding door"));
// The prompt shows the saved description, and a later save keeps it.
s.cmdline("blocks.description").unwrap();
assert!(s.prompt_text().contains("<Sliding door>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
s.execute("line", &json!({ "points": [[0, 0], [0, 1]] })).unwrap();
s.execute("bclose", &json!({})).unwrap();
assert_eq!(description(&s, "Window").as_deref(), Some("Sliding door"));
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
// An undo in the editor after saving over Window leaves Window's new description.
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [0, 2]] })).unwrap();
s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap();
s.execute("undo", &json!({})).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({})).unwrap();
assert_eq!(description(&s, "Window").as_deref(), Some("Pocket door"));
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
}
#[test]
fn undo_after_bsaveas_does_not_give_the_old_block_the_new_description() {
let mut s = Session::new();
door(&mut s);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [0, 3]] })).unwrap();
s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap();
// Undo goes back past the line to the editor's drawing as it was before BSAVEAS.
s.execute("undo", &json!({})).unwrap();
s.execute("bsave", &json!({})).unwrap();
assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door"));
assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door"));
// In the editor, only the edited block's description changes.
assert!(s.execute("blocks.description", &json!({ "name": "Door", "description": "x" })).is_err());
s.execute("bclose", &json!({})).unwrap();
assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door"));
assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door"));
}
#[test]
fn a_new_block_in_the_block_editor_takes_its_description() {
let mut s = Session::new();
s.execute("bedit", &json!({ "name": "Table", "create": true })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
s.execute("blocks.description", &json!({ "description": "Desk" })).unwrap();
assert_eq!(saved_description(&s, "Table"), None);
// Saved under another name first, the started block is not created empty.
s.execute("bsaveas", &json!({ "name": "Desk" })).unwrap();
assert_eq!(saved_description(&s, "Desk").as_deref(), Some("Desk"));
assert_eq!(saved_description(&s, "Table"), None);
s.execute("bclose", &json!({})).unwrap();
assert!(s.doc().unwrap().block("Table").is_none());
s.execute("bedit", &json!({ "name": "Chair", "create": true })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap();
s.execute("blocks.description", &json!({ "name": "chair", "description": "Office chair" })).unwrap();
s.execute("bclose", &json!({})).unwrap();
assert_eq!(description(&s, "Chair").as_deref(), Some("Office chair"));
assert_eq!(s.doc().unwrap().block("Chair").unwrap().entities.len(), 1);
}
+125 -4
View File
@@ -34,6 +34,45 @@ fn area_object_add_and_subtract_keep_a_running_total() {
assert!(s.running.is_none(), "{}", s.prompt_text());
}
#[test]
fn area_too_large_to_hold_is_refused() {
// A 2e154 square has an area of 4e308, past f64's range; its perimeter fits.
let big = json!([[0, 0], [2e154, 0], [2e154, 2e154], [0, 2e154]]);
let mut s = Session::new();
let err = s.execute("area", &json!({ "points": big })).unwrap_err();
assert!(err.to_string().contains("the area is too large"), "{err}");
// Two sides of 1e308: the area fits, the perimeter does not.
let long = json!([[0, 0], [1e308, 0], [1e308, 1e-300]]);
let err = s.execute("area", &json!({ "points": long })).unwrap_err();
assert!(err.to_string().contains("the perimeter is too large"), "{err}");
// Typed, nothing is reported.
let (_, log) = run(&["area", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]);
assert!(has(&log, "The area is too large."), "{log:?}");
assert!(!has(&log, "Area = "), "{log:?}");
// Areas of 8.1e307 that each fit overflow the running total at the third; the total keeps two.
let sq = ["0,0", "9e153,0", "9e153,9e153", "0,9e153", ""];
for mode in ["a", "s"] {
let mut lines = vec!["area", mode];
for _ in 0..3 {
lines.extend(sq);
}
let (s, log) = run(&lines);
assert!(has(&log, "The total area is too large."), "{mode}: {log:?}");
assert_eq!(log.iter().filter(|l| l.contains("Total area = ")).count(), 2, "{mode}: {log:?}");
assert!(s.running.is_some(), "{mode}: the command goes on");
}
// MEASUREGEOM's Volume option does not keep such an area waiting for a height: the next area is measured.
let mut lines = vec!["measuregeom", "v", "a", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""];
lines.extend(["0,0", "1,0", "1,1", "0,1", "", "2"]);
let (s, log) = run(&lines);
assert!(has(&log, "The area is too large."), "{log:?}");
assert!(has(&log, "Total volume = 2"), "{log:?}");
assert!(s.running.is_some(), "the command goes on");
}
#[test]
fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
// Open, 160 long; the closing side back to 0,0 would add 116.619.
@@ -42,10 +81,7 @@ fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() {
}
#[test]
fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() {
let (s, log) = run(&["area", "0,0", "a"]);
assert!(has(&log, "not available yet"), "{log:?}");
assert!(s.running.is_some());
fn area_length_extends_the_last_segment() {
// Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10.
let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]);
assert!(has(&log, "Area = 50.0000"), "{log:?}");
@@ -58,6 +94,91 @@ fn dist_multiple_points_sums_the_segments() {
assert!(s.running.is_none(), "{}", s.prompt_text());
}
#[test]
fn area_arc_option_measures_arc_segments() {
// A 10 x 10 square whose right side is a half circle of radius 5 bulging out:
// area 100 + 12.5 pi, perimeter 30 + 5 pi.
let expected = "Area = 139.2699, Perimeter = 45.7080";
// Arc mode: a point ends an arc that continues the last segment; Line goes back to lines.
let (s, _) = run(&["area", "0,0", "10,0", "a"]);
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
assert!(s.prompt_text().contains("CEnter"), "{}", s.prompt_text());
let (s, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]);
assert!(has(&log, expected), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// The same half circle by its center, by its included angle and by a second point.
for arc in [&["ce", "10,5", "10,10"][..], &["a", "180", "10,10"], &["s", "15,5", "10,10"]] {
let mut lines = vec!["area", "0,0", "10,0", "a"];
lines.extend_from_slice(arc);
lines.extend(["l", "0,10", ""]);
let (_, log) = run(&lines);
assert!(has(&log, expected), "{arc:?}: {log:?}");
}
// After the arc, Length continues along its end tangent (-X): 10 to 0,10.
let (_, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "l", "10", ""]);
assert!(has(&log, expected), "{log:?}");
// Undo removes the arc segment; arc mode stays on.
let (s, _) = run(&["area", "0,0", "10,0", "a", "10,10", "u"]);
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
// CLose ends with an arc back to the first point: center 0,10, radius 10, 270 degrees.
let (s, log) = run(&["area", "0,0", "10,0", "10,10", "a", "cl"]);
assert!(has(&log, "Area = 335.6194, Perimeter = 67.1239"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// No arc continues the last segment back to the first point when it lies straight behind.
let (s, log) = run(&["area", "0,0", "10,0", "a", "cl"]);
assert!(has(&log, "Invalid arc endpoint."), "{log:?}");
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
// Add mode keeps its prefix in arc mode and adds the curved area to the total.
let (s, _) = run(&["area", "a", "0,0", "10,0", "a"]);
assert!(s.prompt_text().contains("(ADD mode) Specify endpoint of arc"), "{}", s.prompt_text());
let (_, log) = run(&["area", "a", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]);
assert!(has(&log, "Total area = 139.2699"), "{log:?}");
// MEASUREGEOM's Volume option measures the same area times the height.
let (_, log) = run(&["measuregeom", "v", "0,0", "10,0", "a", "10,10", "l", "0,10", "", "2"]);
assert!(has(&log, "Volume = 278.5398"), "{log:?}");
}
#[test]
fn dist_multiple_points_arc_option_adds_arc_lengths() {
// 10 along X, then a half circle of radius 5 up to 10,10: 10 + 5 pi.
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10"]);
assert!(has(&log, "Distance = 25.7080"), "{log:?}");
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text());
// Line, then Length continues along the tangent at the arc's end (-X): 5 more.
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "l", "l", "5", ""]);
assert!(has(&log, "Distance = 30.7080"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// Undo removes the arc segment.
let (_, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "u", ""]);
assert_eq!(log.iter().filter(|l| l.contains("Distance = 10.0000")).count(), 2, "{log:?}");
// CLose adds an arc back to the first point and ends: 20 + 15 pi.
let (s, log) = run(&["dist", "0,0", "m", "10,0", "10,10", "a", "cl"]);
assert!(has(&log, "Distance = 67.1239"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// CLose needs a second point.
let (s, log) = run(&["dist", "0,0", "m", "a", "cl"]);
assert!(has(&log, "CLose needs at least two points."), "{log:?}");
assert!(s.running.is_some());
// No arc continues the last segment to a point straight behind it.
for end in ["5,0", "cl"] {
let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", end]);
assert!(has(&log, "Invalid arc endpoint."), "{end}: {log:?}");
assert!(s.prompt_text().contains("Specify endpoint of arc"), "{end}: {}", s.prompt_text());
}
}
#[test]
fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() {
for end in [90, 45] {
+1 -1
View File
@@ -39,7 +39,7 @@ fn typed_layout_options_prompt_for_names() {
s.script("LAYOUT\n\nModel\n").unwrap();
assert_eq!(s.layout_space(), Space::Model);
// ? lists; Template and SAveas report and end without taking the next input.
// ? lists; Template reports and ends without taking the next input.
let n = s.log.len();
s.cmdline("LAYOUT ? Sheet*").unwrap();
assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]);
+2
View File
@@ -10,6 +10,7 @@ mod attedit_prompts;
mod attrib_modes;
mod attrib_props;
mod attribute_tools;
mod block_description;
mod block_keep;
mod block_redefine;
mod blockreplace;
@@ -75,6 +76,7 @@ mod modify_curves;
mod mtext_columns;
mod mtext_options;
mod multiline_attributes;
mod named_viewports;
mod named_views;
mod non_finite_edits;
mod offset_closed_spline;
@@ -161,6 +161,99 @@ fn multiple_points_length_and_undo_keep_the_measurement_units() {
assert_eq!(s.log.last().unwrap(), "Distance = 20.0000");
}
#[test]
fn multiple_points_arc_options_keep_the_measurement_units() {
let (d, _) = drawing();
let mut s = session(d);
s.start("dist").unwrap();
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
s.input(Input::Keyword("Multiple points".into())).unwrap();
s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
// A half circle of radius 2.5 on paper, ended by its included angle, is radius 5 in the
// model: 10 + 5 pi.
s.input(Input::Keyword("Arc".into())).unwrap();
s.input(Input::Keyword("CEnter".into())).unwrap();
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
s.input(Input::Keyword("Angle".into())).unwrap();
s.input(Input::Text("180".into())).unwrap();
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
let base = s.current_prompt().unwrap().base.unwrap();
assert!(base.near(Vec2::new(100.0, 55.0), 1e-9), "the path stays in paper coordinates: {base:?}");
s.input(Input::Enter).unwrap();
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
// A typed radius is in model units: 5 is 2.5 on paper, the same half circle.
let start = |s: &mut Session| {
s.start("dist").unwrap();
s.input_snapped(pick(s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
s.input(Input::Keyword("Multiple points".into())).unwrap();
s.input_snapped(pick(s, Vec2::new(100.0, 50.0), mode::INT)).unwrap();
s.input(Input::Keyword("Arc".into())).unwrap();
};
start(&mut s);
for i in [Input::Keyword("Radius".into()), Input::Text("5".into()), Input::Keyword("Angle".into()), Input::Text("180".into()), Input::Enter] {
s.input(i).unwrap();
}
assert_eq!(s.log.last().unwrap(), "Distance = 25.7080");
// A snapped end point off the circle gives the direction of the end: the model end is the
// arc's, not the snapped point (0,0). Radius 5 from angle -90 to the angle of (-2,-1).
start(&mut s);
s.input(Input::Keyword("CEnter".into())).unwrap();
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap();
let sweep = (-1.0f64).atan2(-2.0) + std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU;
assert_eq!(s.log.last().unwrap(), &format!("Distance = {:.4}", 10.0 + 5.0 * sweep));
s.input(Input::Enter).unwrap();
// A typed end point is a paper point, as in line mode: the path is measured in paper units.
start(&mut s);
s.input(Input::Keyword("CEnter".into())).unwrap();
s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap();
s.input(Input::Point(Vec2::new(100.0, 55.0))).unwrap();
assert_eq!(s.log.last().unwrap(), "Distance = 12.8540");
}
#[test]
fn an_arc_end_picked_through_another_viewport_uses_paper_units() {
let (mut d, paper) = drawing();
let mut vp = match &d.entity(viewport(&d, &paper)).unwrap().kind {
EntityKind::Viewport(vp) => vp.clone(),
_ => panic!("viewport fixture"),
};
vp.center.x = 120.0;
vp.view_height = 20.0;
vp.id = 3;
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
let mut s = session(d);
let mut measure = |snap: bool| {
s.start("dist").unwrap();
let first = pick(&s, Vec2::new(95.0, 50.0), mode::END);
let second = pick(&s, Vec2::new(100.0, 50.0), mode::INT);
let end = pick(&s, Vec2::new(110.0, 50.0), mode::END);
assert_ne!(first.viewport.unwrap().viewport, end.viewport.unwrap().viewport);
for (n, hit) in [first, second].into_iter().enumerate() {
if n == 1 {
s.input(Input::Keyword("Multiple points".into())).unwrap();
}
if snap { s.input_snapped(hit).unwrap() } else { s.input(Input::Point(hit.point)).unwrap() }
}
s.input(Input::Keyword("Arc".into())).unwrap();
s.input(Input::Keyword("Second pt".into())).unwrap();
s.input(Input::Point(Vec2::new(105.0, 52.0))).unwrap();
if snap {
s.input_snapped(end).unwrap()
} else {
s.input(Input::Point(end.point)).unwrap()
}
s.input(Input::Enter).unwrap();
s.log.last().unwrap().clone()
};
let typed = measure(false);
assert_eq!(typed, "Distance = 16.0347", "5 along X, then the arc through 105,52 to 110,50");
assert_eq!(measure(true), typed);
}
#[test]
fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() {
let (d, _) = drawing();
+101
View File
@@ -0,0 +1,101 @@
//! View > Viewports > Named Viewports...: the saved model space viewport configurations, the one
//! the Model tab shows, and restoring or deleting one (its tiles in the Model tab, layout
//! viewports in a layout).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Space};
use serde_json::json;
/// A drawing with "Two" (two tiles side by side) and "Quad" (four tiles) saved, showing one view.
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap();
s.execute("-vports", &json!({ "option": "4", "applyTo": "display" })).unwrap();
s.execute("-vports", &json!({ "option": "save", "name": "Quad" })).unwrap();
s.execute("-vports", &json!({ "option": "single" })).unwrap();
s
}
fn rects(s: &Session) -> Vec<[f64; 4]> {
let mut r: Vec<[f64; 4]> = s.model_tiles().iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect();
r.sort_by(|a, b| a.partial_cmp(b).unwrap());
r
}
fn layout_viewports(s: &Session) -> usize {
let d = s.doc().unwrap();
d.space(&Space::Paper("Layout1".into())).unwrap().iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count()
}
#[test]
fn named_viewports_lists_restores_and_deletes_in_the_model_tab() {
let mut s = session();
let list = s.execute("vports.named", &json!({})).unwrap();
assert_eq!(list["configurations"], json!([{ "name": "Two", "viewports": 2 }, { "name": "Quad", "viewports": 4 }]));
assert_eq!(list["current"], json!(null), "one view matches no saved configuration");
assert_eq!(s.viewport_configuration(), None);
s.execute("vports.named", &json!({ "name": "two" })).unwrap();
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
assert_eq!(s.viewport_configuration().as_deref(), Some("Two"));
assert_eq!(s.execute("vports.named", &json!({})).unwrap()["current"], json!("Two"));
s.undo().unwrap();
assert!(rects(&s).is_empty(), "restoring is one undo step");
let r = s.execute("vports.named", &json!({ "name": "QUAD", "delete": true })).unwrap();
assert_eq!(r["deleted"], json!("Quad"));
let names: Vec<String> = s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["Two".to_string()]);
s.undo().unwrap();
assert!(s.doc().unwrap().vports.iter().any(|c| c.name == "Quad"), "deleting is one undo step");
}
#[test]
fn named_viewports_refuses_unknown_names_and_a_delete_that_is_not_a_boolean() {
let mut s = session();
for bad in [
json!({ "name": "nope" }),
json!({ "name": "*Active" }),
json!({ "name": "Two", "delete": "yes" }),
json!({ "name": "nope", "delete": true }),
] {
assert!(s.execute("vports.named", &bad).is_err(), "{bad}");
}
// Only the exact name is deleted, never a wildcard match.
assert!(s.execute("vports.named", &json!({ "name": "*", "delete": true })).is_err());
assert_eq!(s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).count(), 2);
}
#[test]
fn named_viewports_places_a_configuration_in_a_layout() {
let mut s = session();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let before = layout_viewports(&s);
let r = s.execute("vports.named", &json!({ "name": "Quad", "p1": [10, 10], "p2": [210, 110] })).unwrap();
assert_eq!(r["configuration"], json!("Quad"));
assert_eq!(layout_viewports(&s), before + 4);
assert!(s.model_tiles().is_empty(), "the Model tab's tiles do not change");
}
#[test]
fn typed_named_viewports_asks_for_the_configuration() {
let mut s = session();
s.start("vports.named").unwrap();
assert!(s.prompt_text().contains("Enter name of viewport configuration to restore"), "{}", s.prompt_text());
s.cmdline("Two").unwrap();
assert!(s.running.is_none());
assert_eq!(rects(&s).len(), 2);
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let before = layout_viewports(&s);
s.start("vports.named").unwrap();
assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text());
s.cmdline("Quad").unwrap();
assert!(s.prompt_text().contains("Specify first corner"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(layout_viewports(&s), before + 4, "Enter fits the configuration to the printable area");
}
+93 -1
View File
@@ -1,5 +1,5 @@
//! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default
//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number
//! template (QNEWTEMPLATE), and a layout saved as a template (LAYOUT SAveas). The io hooks here keep drawings in memory: a "file" holds the number
//! of the drawing written, so the test sees exactly what the engine hands the file formats.
use std::sync::Mutex;
@@ -105,3 +105,95 @@ fn a_template_that_cannot_be_read_makes_no_drawing() {
assert!(s.execute("new", &json!({ "template": 42 })).is_err());
assert_eq!(s.docs.len(), docs);
}
/// The drawing the io hooks were last handed for a file whose name ends with `name`.
fn written(name: &str) -> Drawing {
FILES.lock().unwrap().iter().rev().find(|(n, _)| n.ends_with(name)).map(|f| f.1.clone()).unwrap()
}
fn layout_names(d: &Drawing) -> Vec<String> {
d.layouts.iter().map(|l| l.name.clone()).collect()
}
/// LAYOUT SAveas: the template holds the layout alone, with the named objects it uses, and the
/// drawing it came from keeps everything.
#[test]
fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() {
hooks();
let mut s = Session::new();
for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] {
s.execute("layer.new", &json!({ "name": name })).unwrap();
}
s.execute("layer.current", &json!({ "name": "PARTS-LAYER" })).unwrap();
let c = s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].clone();
s.execute("block", &json!({ "name": "PARTS", "base": [0, 0], "handles": [c], "keep": "retain" })).unwrap();
// WALLS has model space objects only, and NOTES none; the sheet's viewport freezes one and recolors the other.
s.execute("layer.current", &json!({ "name": "WALLS" })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [0, 10]] })).unwrap();
let vp = s.execute("layout.new", &json!({ "name": "Sheet" })).unwrap()["viewport"].clone();
s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["WALLS"], "colors": { "NOTES": 1 } })).unwrap();
s.execute("layout.set", &json!({ "name": "Sheet" })).unwrap();
s.execute("layer.current", &json!({ "name": "TITLE" })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count();
let p = path("sheet.dwt");
let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap();
assert_eq!(r["name"], json!("Sheet"), "the current layout by default");
assert_eq!(r["path"], json!(p));
let t = written("sheet.dwt");
assert_eq!(layout_names(&t), ["Sheet"]);
assert_eq!(t.active_layout.as_deref(), Some("Sheet"));
assert_eq!(t.model.iter().count(), 0, "no model space objects");
assert_eq!(t.layout("Sheet").unwrap().entities.iter().count(), sheet_objects, "the layout's viewport and line");
assert!(t.layer("TITLE").is_some() && t.layer("0").is_some());
assert!(t.layer("UNUSED").is_none() && t.layer("PARTS-LAYER").is_none(), "unused layers are left out");
assert!(t.block("PARTS").is_none(), "unused blocks are left out");
// Layers the viewport freezes or recolors stay, and so do those settings.
assert!(t.layer("WALLS").is_some() && t.layer("NOTES").is_some());
let sheet_vp = t.layout("Sheet").unwrap().entities.iter().find_map(|e| match &e.kind {
cadcraft_engine::doc::EntityKind::Viewport(v) if v.id != 1 => Some(v.clone()),
_ => None,
});
let sheet_vp = sheet_vp.unwrap();
assert_eq!(sheet_vp.frozen_layers, ["WALLS"]);
assert_eq!(sheet_vp.layer_colors.len(), 1);
// The drawing is not changed.
let d = s.doc().unwrap();
assert_eq!(layout_names(d), ["Layout1", "Layout2", "Sheet"]);
assert_eq!(d.model.iter().count(), 2);
assert!(d.layer("UNUSED").is_some() && d.block("PARTS").is_some());
// A drawing started from the template has the layout.
s.execute("new", &json!({ "template": p })).unwrap();
assert_eq!(layout_names(s.doc().unwrap()), ["Sheet"]);
}
#[test]
fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() {
hooks();
let mut s = Session::new();
s.cmdline("LAYOUT").unwrap();
s.cmdline("sa").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template <Layout1>:");
s.cmdline("Layout2").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name <Layout2.dwt>:");
// No extension: the template is a .dwt.
let p = path("typed");
s.cmdline(&p).unwrap();
assert!(s.current_prompt().is_none());
let saved = format!("{p}.dwt");
assert!(s.log.iter().any(|l| *l == format!("Layout \"Layout2\" saved to template \"{saved}\".")), "{:?}", s.log);
assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]);
// The JSON form of LAYOUT, other file types, a missing path and an unknown layout.
let dxf = path("sheet1.dxf");
let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap();
assert_eq!(r["name"], json!("Layout1"));
assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]);
assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err());
assert!(s.execute("layout.saveas", &json!({})).is_err());
assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err());
}
+79
View File
@@ -0,0 +1,79 @@
//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid
//! (a floor plan of many apartments).
//!
//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]`
//!
//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side
//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall
//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR,
//! the last run's output is written there as `bench.FORMAT` to compare builds.
use std::time::{Duration, Instant};
use cadcraft_doc::{Drawing, Space};
use cadcraft_geom::{Mat3, Vec2};
fn drawing(grid: usize) -> Drawing {
let src = include_bytes!("../../../examples/apartment.dxf");
let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else {
eprintln!("apartment.dxf did not read");
std::process::exit(1);
};
let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default();
let ext = d.extents(&Space::Model);
let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1);
for i in 0..grid {
for j in 0..grid {
if (i, j) == (0, 0) {
continue;
}
let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64));
for e in &ents {
let mut e = e.clone();
e.handle = d.new_handle();
e.kind.transform(&m);
if let Some(s) = d.space_mut(&Space::Model) {
s.push(e);
}
}
}
}
d
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let format = args.first().map(String::as_str).unwrap_or("png").to_string();
let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10);
let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1);
let out_dir = args.get(3);
let d = drawing(grid);
eprintln!("{} entities", d.entity_count());
let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] };
for f in formats {
let name = format!("bench.{f}");
let mut times: Vec<Duration> = Vec::new();
let mut last = Vec::new();
for _ in 0..runs {
let t = Instant::now();
match cadcraft_io::write(&d, &name) {
Ok(bytes) => {
times.push(t.elapsed());
last = bytes;
}
Err(e) => {
eprintln!("{f}: {e}");
std::process::exit(1);
}
}
}
times.sort();
let ms = |t: Duration| t.as_secs_f64() * 1000.0;
println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len());
if let Some(dir) = out_dir
&& let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last)
{
eprintln!("{name}: {e}");
}
}
}
+72 -5
View File
@@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
let mut pm = Pixmap::new(view.width, view.height)?;
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
let vis = view.world_bounds();
let mut hairlines = Hairlines::default();
let mut screen: Vec<(f32, f32)> = Vec::new();
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
@@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
paint.anti_alias = o.antialias;
let pts = list.points(p);
if p.kind != Kind::Polyline {
hairlines.flush(&mut pm, o.antialias);
}
match p.kind {
Kind::Polyline => {
let bb = Bounds2::from_points(pts.iter().copied());
if !bb.intersects(&vis) {
continue;
}
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
screen.clear();
screen.extend(pts.iter().map(|q| view.to_screen(*q)));
if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) {
hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias);
continue;
}
hairlines.flush(&mut pm, o.antialias);
let mut pb = PathBuilder::new();
for (i, q) in pts.iter().enumerate() {
let (x, y) = view.to_screen(*q);
for (i, (x, y)) in screen.iter().enumerate() {
if i == 0 {
pb.move_to(x, y);
pb.move_to(*x, *y);
} else {
pb.line_to(x, y);
pb.line_to(*x, *y);
}
}
if let Some(path) = pb.finish() {
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
@@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
}
}
}
hairlines.flush(&mut pm, o.antialias);
Some(pm)
}
/// Whether tiny-skia strokes a line `width` pixels wide (untransformed) as a hairline: zero
/// width, or at most one pixel when anti-aliased.
fn is_hairline(width: f32, antialias: bool) -> bool {
width == 0.0 || (antialias && width <= 1.0)
}
/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a
/// path's integer bounds could overflow, so such a polyline is stroked on its own as before.
const MAX_BATCH_COORD: f32 = 1.0e8;
fn batchable(v: f32) -> bool {
v.is_finite() && v.abs() <= MAX_BATCH_COORD
}
/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path.
///
/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with
/// its own caps, a polyline's first and last segment extended for round caps), so one path of
/// several polylines paints exactly the pixels, in the same order, as stroking them one by one;
/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled
/// outlines, where overlaps would cover once instead of twice, and are never batched.
#[derive(Default)]
struct Hairlines {
key: Option<([u8; 4], f32)>,
pb: PathBuilder,
}
impl Hairlines {
fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) {
if self.key.is_some_and(|k| k != (rgba, width)) {
self.flush(pm, antialias);
}
self.key = Some((rgba, width));
for (i, (x, y)) in pts.iter().enumerate() {
if i == 0 {
self.pb.move_to(*x, *y);
} else {
self.pb.line_to(*x, *y);
}
}
}
fn flush(&mut self, pm: &mut Pixmap, antialias: bool) {
let Some(([r, g, b, a], width)) = self.key.take() else { return };
let pb = std::mem::take(&mut self.pb);
let Some(path) = pb.finish() else { return };
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, a);
paint.anti_alias = antialias;
let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
self.pb = path.clear();
}
}
/// Render and encode as PNG.
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
render(list, view, o)?.encode_png().ok()
+123
View File
@@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() {
assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline");
}
}
/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines.
fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap {
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
let mut pm = Pixmap::new(view.width, view.height).unwrap();
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
paint.anti_alias = o.antialias;
let mut pb = PathBuilder::new();
match p.kind {
Kind::Polyline => {
for (i, q) in list.points(p).iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
if let Some(path) = pb.finish() {
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
Kind::Tris => {
for t in list.points(p).chunks(3) {
let [a, b, c] = t else { continue };
let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c));
pb.move_to(ax, ay);
pb.line_to(bx, by);
pb.line_to(cx, cy);
pb.close();
}
if let Some(path) = pb.finish() {
pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
_ => panic!("not drawn by this reference"),
}
}
pm
}
fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) {
let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts };
let start = arr.len() as u32;
arr.extend_from_slice(pts);
for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) {
l.bounds.add(*p);
}
l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 });
}
/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly
/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight,
/// fills, unusable coordinates) and with overlaps, caps and dots.
#[test]
fn raster_hairline_batches_match_one_by_one() {
let red = Rgb(255, 0, 0);
let blue = Rgb(0, 0, 255);
let mut l = DisplayList::default();
// A hatch-like run of crossing, overlapping and touching lines in one colour.
for i in 0..40 {
let x = f64::from(i) * 0.37;
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]);
}
// Zero-length (a dot), single-point (nothing), and repeated lines.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
// Transparent lines build up where they overlap, within a run and across runs.
for i in 0..10 {
let y = f64::from(i) * 0.2;
push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]);
}
push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]);
// A lineweight (a filled outline when lineweights are shown), a fill, then the run again.
push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]);
push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]);
// Unusable or far-out coordinates are stroked on their own, as before.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]);
// Partly and wholly off screen.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]);
let view = View::fit(&l.bounds, 160, 120, 0.05);
let bg = Rgb(33, 40, 48);
for o in [
RasterOptions::default(),
RasterOptions { antialias: false, ..Default::default() },
RasterOptions { hairline: 0.5, ..Default::default() },
RasterOptions { hairline: 0.0, antialias: false, ..Default::default() },
RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() },
RasterOptions { hairline: 1.5, background: bg, ..Default::default() },
] {
let fast = render(&l, &view, &o).unwrap();
let reference = render_one_by_one(&l, &view, &o);
assert!(fast.data() == reference.data(), "pixels differ with {o:?}");
}
}
/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by
/// line.
#[test]
fn raster_hatch_pattern_matches_one_by_one() {
let mut d = sample();
d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap();
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.len() > 20);
let view = View::fit(&l.bounds, 300, 200, 0.05);
for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] {
assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data());
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
}
ui.separator();
ui.label(crate::tl!("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() {
v.key = k;
+3 -3
View File
@@ -86,7 +86,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut deg = current;
ui.horizontal(|ui| {
let label = ui.label(crate::tl!("Increment angle"));
let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
ui.selectable_value(&mut deg, a, format!("{a}"));
}
@@ -231,7 +231,7 @@ const INSERTION_UNITS: [&str; 25] = [
/// A combo box over `(value, label)` options.
fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)]) {
let current = options.iter().find(|o| o.0 == *value).map(|o| o.1.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| {
for (k, label) in options {
ui.selectable_value(value, *k, label);
}
@@ -431,7 +431,7 @@ fn insert_block(app: &mut CadApp, name: &str) {
/// Interface Language combo; selecting a language runs `ui.language` with its code.
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
for preference in crate::i18n::Preference::for_picker() {
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()}));
+2 -2
View File
@@ -498,7 +498,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
}
ui.end_row();
ui.label(crate::tl!("Shape"));
egui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("hatch_shape").selected_text(shape_label(&f.shape)).show_ui(ui, |ui| {
for name in SHAPES {
ui.selectable_value(&mut f.shape, name.to_string(), shape_label(name));
}
@@ -545,7 +545,7 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
f.origin = None;
}
ui.add_enabled_ui(f.origin.is_none(), |ui| {
egui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("hatch_origin_mode").selected_text(origin_label(&f.origin_mode)).show_ui(ui, |ui| {
for m in ORIGIN_MODES {
ui.selectable_value(&mut f.origin_mode, m.to_string(), origin_label(m));
}
+2
View File
@@ -96,6 +96,7 @@
Blend دمج تدريجي
Block كتلة
Block Attribute Manager... مدير سمات الكتلة...
Block Description... وصف الكتلة...
Block Editor محرر الكتل
Block Reference مرجع كتلة
Block... كتلة...
@@ -587,6 +588,7 @@
Name الاسم
Name (A–Z) الاسم (أ–ي)
Named مسمى
Named Viewports... أطر العرض المسماة...
Named Views... طرق عرض مسماة...
Nearest الأقرب
New جديد
1 # CADCraft Arabic interface catalog — original translations.
96
97
98
99
100
101
102
588
589
590
591
592
593
594
+2
View File
@@ -92,6 +92,7 @@
Blend Spojit křivky
Block Blok
Block Attribute Manager... Správce atributů bloků...
Block Description... Popis bloku...
Block Editor Editor bloků
Block Reference Reference bloku
Block... Blok...
@@ -537,6 +538,7 @@
N S
Name Název
Name (A–Z) Název (A–Z)
Named Viewports... Pojmenované výřezy...
Named Views... Pojmenované pohledy...
Nearest Nejbližší
New Nový
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
92
93
94
95
96
97
98
538
539
540
541
542
543
544
+2
View File
@@ -67,6 +67,7 @@
Blend Übergang
Block Block
Block Attribute Manager... Blockattribut-Manager...
Block Description... Blockbeschreibung...
Block Editor Blockeditor
Block Reference Blockreferenz
Block... Block...
@@ -457,6 +458,7 @@
N N
Name Name
Name (A–Z) Name (A–Z)
Named Viewports... Benannte Ansichtsfenster...
Nearest Nächstgelegener
New Neu
New (metric) Neu (metrisch)
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
67
68
69
70
71
72
73
458
459
460
461
462
463
464
+2
View File
@@ -100,6 +100,7 @@
Blend Ομαλή σύνδεση (Blend)
Block Μπλοκ (Block)
Block Attribute Manager... Διαχείριση ιδιοτήτων μπλοκ (Block Attributes)...
Block Description... Περιγραφή μπλοκ...
Block Editor Επεξεργαστής μπλοκ (Block Editor)
Block Reference Αναφορά μπλοκ (Block Reference)
Block to create or edit Μπλοκ προς δημιουργία ή επεξεργασία
@@ -640,6 +641,7 @@
Name Όνομα
Name (A–Z) Όνομα (Α–Ω)
Named Με όνομα
Named Viewports... Αποθηκευμένα παράθυρα προβολής...
Named Views... Αποθηκευμένες όψεις...
Nearest Πλησιέστερο (Nearest)
New Νέο
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
100
101
102
103
104
105
106
641
642
643
644
645
646
647
+2
View File
@@ -87,6 +87,7 @@
Blend Fusión de curvas
Block Bloque
Block Attribute Manager... Administrador de atributos de bloque...
Block Description... Descripción de bloque...
Block Editor Editor de bloques
Block Reference Referencia de bloque
Block... Bloque...
@@ -567,6 +568,7 @@
N N
Name Nombre
Name (A–Z) Nombre (A–Z)
Named Viewports... Ventanas gráficas con nombre...
Named Views... Vistas con nombre...
Nearest Cercano
New Nuevo
1 # CADCraft Spanish interface catalog — original translations.
87
88
89
90
91
92
93
568
569
570
571
572
573
574
+2
View File
@@ -67,6 +67,7 @@
Blend Raccord lissé
Block Bloc
Block Attribute Manager... Gestionnaire des attributs de bloc...
Block Description... Description de bloc...
Block Editor Éditeur de blocs
Block Reference Référence de bloc
Block... Bloc...
@@ -452,6 +453,7 @@
N N
Name Nom
Name (A–Z) Nom (A–Z)
Named Viewports... Fenêtres nommées...
Nearest Plus proche
New Nouveau
New (metric) Nouveau (métrique)
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
67
68
69
70
71
72
73
453
454
455
456
457
458
459
+2
View File
@@ -114,6 +114,7 @@
Blend Átmenetgörbe
Block Blokk
Block Attribute Manager... Blokkattribútum-kezelő...
Block Description... Blokkleírás...
Block Editor Blokkszerkesztő
Block Reference Blokkhivatkozás
Block to create or edit Létrehozandó vagy szerkesztendő blokk
@@ -650,6 +651,7 @@
Name Név
Name (A–Z) Név (A–Z)
Named Elnevezett
Named Viewports... Elnevezett nézetablakok...
Named Views... Elnevezett nézetek...
Nearest Legközelebbi
New Új
Can't render this file because it contains an unexpected character in line 16 and column 72.
+2
View File
@@ -97,6 +97,7 @@ menu Modify Edita
Blend Fondi
Block Blocco
Block Attribute Manager... Gestore attributi blocco...
Block Description... Descrizione blocco...
Block Editor Editor blocchi
Block Reference Riferimento di blocco
Block... Blocco...
@@ -549,6 +550,7 @@ menu Modify Edita
N N
Name Nome
Name (A–Z) Nome (A–Z)
Named Viewports... Finestre con nome...
Named Views... Viste con nome...
Nearest Vicino
New Nuovo
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
97
98
99
100
101
102
103
550
551
552
553
554
555
556
+2
View File
@@ -92,6 +92,7 @@
Blend 곡선 연결
Block 블록
Block Attribute Manager... 블록 속성 관리자...
Block Description... 블록 설명...
Block Editor 블록 편집기
Block Reference 블록 참조
Block... 블록...
@@ -539,6 +540,7 @@
N 북
Name 이름
Name (A–Z) 이름(A–Z)
Named Viewports... 명명된 뷰포트...
Named Views... 명명된 뷰...
Nearest 근처점
New 새로 만들기
1 # CADCraft Korean interface catalog — original Korean translations.
92
93
94
95
96
97
98
540
541
542
543
544
545
546
+2
View File
@@ -86,6 +86,7 @@
Blend Curva de transição
Block Bloco
Block Attribute Manager... Gerenciador de atributos de bloco...
Block Description... Descrição do bloco...
Block Editor Editor de blocos
Block Reference Referência de bloco
Block... Bloco...
@@ -565,6 +566,7 @@
N N
Name Nome
Name (A–Z) Nome (A–Z)
Named Viewports... Viewports nomeados...
Named Views... Vistas nomeadas...
Nearest Mais próximo
New Novo
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
86
87
88
89
90
91
92
566
567
568
569
570
571
572
+2
View File
@@ -67,6 +67,7 @@
Blend Сгладить
Block Блок
Block Attribute Manager... Диспетчер атрибутов блоков...
Block Description... Описание блока...
Block Editor Редактор блоков
Block Reference Вхождение блока
Block... Блок...
@@ -452,6 +453,7 @@
N С
Name Название
Name (A–Z) Название (А–Я)
Named Viewports... Именованные видовые экраны...
Nearest Ближайшая точка
New Создать
New (metric) Создать (метрический)
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
67
68
69
70
71
72
73
453
454
455
456
457
458
459
+2
View File
@@ -90,6 +90,7 @@
Blend Mjuk övergång
Block Block
Block Attribute Manager... Hantera blockattribut...
Block Description... Blockbeskrivning...
Block Editor Blockredigerare
Block Reference Blockreferens
Block... Block...
@@ -569,6 +570,7 @@
N N
Name Namn
Name (A–Z) Namn (A–Ö)
Named Viewports... Namngivna vyfönster...
Named Views... Namngivna vyer...
Nearest Närmaste
New Ny
1 # CADCraft Swedish interface catalog — original translations by the CADCraft contributors.
90
91
92
93
94
95
96
570
571
572
573
574
575
576
+2
View File
@@ -114,6 +114,7 @@
Blend Harmanla
Block Blok
Block Attribute Manager... Blok Öznitelik Yöneticisi...
Block Description... Blok Açıklaması...
Block Editor Blok Düzenleyici
Block Reference Blok Referansı
Block to create or edit Oluşturulacak ya da düzenlenecek blok
@@ -651,6 +652,7 @@
Name Ad
Name (A–Z) Ad (A–Z)
Named Adlandırılmış
Named Viewports... Adlandırılmış Görünüm Pencereleri...
Named Views... Adlandırılmış Görünümler...
Nearest En Yakın
New Yeni
Can't render this file because it contains an unexpected character in line 11 and column 13.
+2
View File
@@ -94,6 +94,7 @@
Blend Плавне з'єднання
Block Блок
Block Attribute Manager... Диспетчер атрибутів блоків...
Block Description... Опис блока...
Block Editor Редактор блоків
Block Reference Посилання на блок
Block... Блок...
@@ -553,6 +554,7 @@
N Пн
Name Назва
Name (A–Z) Назва (А–Я)
Named Viewports... Іменовані видові екрани...
Named Views... Іменовані види...
Nearest Найближча точка
New Створити
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
94
95
96
97
98
99
100
554
555
556
557
558
559
560
+2
View File
@@ -67,6 +67,7 @@
Blend 过渡
Block 块
Block Attribute Manager... 块属性管理器...
Block Description... 块描述...
Block Editor 块编辑器
Block Reference 块参照
Block... 块...
@@ -1121,6 +1122,7 @@
Open Recent 打开最近的图形
Sheet Books to PDF... 图纸册转 PDF...
Drawing Recovery Manager... 图形恢复管理器...
Named Viewports... 命名视口...
Named Views... 命名视图...
Polygonal Viewport 多边形视口
From Template... 来自模板...
1 # CADCraft Simplified Chinese interface catalog — original translations.
67
68
69
70
71
72
73
1122
1123
1124
1125
1126
1127
1128
+1 -1
View File
@@ -281,7 +281,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
// Layer states.
ui.label(crate::tl!("Layer State"));
let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into());
egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| {
for s in &states {
if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() {
st.state_sel = Some(s.clone());
+13 -8
View File
@@ -1,5 +1,6 @@
//! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style,
//! Table Style, Multileader Style, Page Setup Manager, Plot and Constraint Settings.
//! Table Style, Multileader Style, Page Setup Manager, Plot, Constraint Settings and Named
//! Viewports.
//!
//! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run
//! the command. The dialogs read the drawing and change it only through the same commands
@@ -16,6 +17,8 @@ use crate::theme::Tokens;
/// The Block Attribute Manager (BATTMAN).
mod battman;
/// Named Viewports (View > Viewports > Named Viewports...).
mod named_vports;
/// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the
/// call carries parameters.
@@ -32,6 +35,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
"mleaderstyle" | "mls" => "mleaderstyle",
"pagesetup" => "pagesetup",
"constraintsettings" | "csettings" => "csettings",
"vports.named" => "namedvports",
"plot" | "print" => {
PLOT_GEN.fetch_add(1, Ordering::Relaxed);
"plot"
@@ -53,6 +57,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool
"mleaderstyle" => mleader_style(app, ctx, open),
"pagesetup" => page_setup(app, ctx, open),
"csettings" => constraint_settings(app, ctx, open),
"namedvports" => named_vports::dialog(app, ctx, open),
"plot" => plot(app, ctx, open),
_ => {
*open = false;
@@ -118,7 +123,7 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
let mut action = None;
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
if library.is_empty() {
ui.label("Every library linetype is loaded.");
} else if ui.selectable_label(false, "All").clicked() {
@@ -445,7 +450,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
Field::Choice(key, label, v, choices) => {
ui.label(*label);
let mut picked = None;
egui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
for c in choices {
if ui.selectable_label(c == v, c).clicked() && c != v {
picked = Some(c.clone());
@@ -599,7 +604,7 @@ fn page_rows(
) -> Option<(&'static str, Value)> {
let mut change = None;
ui.label("Printer/plotter");
egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {
if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked()
&& device_label(page) != "None"
{
@@ -618,7 +623,7 @@ fn page_rows(
// The device's papers, else the standard sizes.
let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device));
ui.label("Paper size");
egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {
craft_ui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {
Some(dev) => {
for p in &dev.papers {
if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {
@@ -647,7 +652,7 @@ fn page_rows(
ui.end_row();
ui.label("Plot area");
ui.add_enabled_ui(rows.areas.len() > 1, |ui| {
egui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| {
for a in rows.areas {
if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && page.plot_area != *a {
change = Some(("plotArea", json!(a)));
@@ -723,7 +728,7 @@ fn page_rows(
}
ui.end_row();
ui.label("Plot style table");
egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| {
if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" {
change = Some(("plotStyleTable", json!("None")));
}
@@ -941,7 +946,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).pivot(egui::Align2::CENTER_CENTER).default_pos(center).show(ctx, |ui| {
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Layout");
egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
for n in &targets {
if ui.selectable_label(*n == st.target, n).clicked() && *n != st.target {
st.page = plot_page(d, n);
+2 -2
View File
@@ -103,7 +103,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<
}
ui.horizontal(|ui| {
ui.label("Block:");
egui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("battman_blocks").selected_text(block.as_str()).width(180.0).show_ui(ui, |ui| {
for b in &blocks {
if ui.selectable_label(*b == block, b).clicked() {
block = b.clone();
@@ -189,7 +189,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<
}
ui.end_row();
ui.label("Layer");
egui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("battman_layer").selected_text(r.layer.as_str()).show_ui(ui, |ui| {
for l in &layers {
if ui.selectable_label(*l == r.layer, l).clicked() && *l != r.layer {
action = act(edit("layer", json!(l)), apply);
+112
View File
@@ -0,0 +1,112 @@
//! Named Viewports (View > Viewports > Named Viewports...): the drawing's saved model space
//! viewport configurations, with the selected one's tiles drawn as a preview. Restore runs
//! `vports.named` in the Model tab; in a layout it starts MVIEW Restore with the name, so the
//! viewports' corners are picked next. Delete runs `vports.named` with `delete`.
use cadcraft_doc::{Space, VportConfig, VportTile};
use cadcraft_engine::Input;
use egui::{RichText, Sense, Stroke, vec2};
use serde_json::{Value, json};
use super::{set_temp, temp};
use crate::CadApp;
use crate::theme::Tokens;
/// Draws the tiles of a configuration in a 180 x 120 box; tile coordinates (0, 0) are the bottom left.
fn preview(ui: &mut egui::Ui, tiles: &[VportTile], t: &Tokens) {
let (rect, _) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 0.0, t.panel);
let at = |x: f64, y: f64| egui::pos2(rect.min.x + x.clamp(0.0, 1.0) as f32 * rect.width(), rect.max.y - y.clamp(0.0, 1.0) as f32 * rect.height());
for tile in tiles.iter().filter(|t| t.min.is_finite() && t.max.is_finite()) {
let r = egui::Rect::from_two_pos(at(tile.min.x, tile.min.y), at(tile.max.x, tile.max.y));
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
}
p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
}
/// Show the dialog; returns the command to run.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> {
let t = Tokens::get();
let Ok(d) = app.session.doc() else {
egui::Window::new("Named Viewports").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.label("Open a drawing first.");
});
return None;
};
let configs: Vec<(String, Vec<VportTile>)> = d.vports.iter().filter(|c| !c.is_active()).map(|c| (c.name.clone(), c.tiles.clone())).collect();
let model = app.session.layout_space() == Space::Model;
let current = if model { app.session.viewport_configuration() } else { None };
let mut sel = temp::<String>(ctx, "namedvp_sel")
.filter(|s| configs.iter().any(|c| c.0 == *s))
.or_else(|| current.clone())
.or_else(|| configs.first().map(|c| c.0.clone()));
let mut action: Option<(&'static str, Value)> = None;
let mut restore = false;
let mut close = false;
egui::Window::new("Named Viewports").open(open).default_width(400.0).resizable(false).show(ctx, |ui| {
if model {
ui.label(format!("Current name: {}", current.as_deref().unwrap_or(VportConfig::ACTIVE)));
} else {
ui.label(RichText::new("Saved model space configurations to place in this layout").color(t.text_dim));
}
ui.separator();
if configs.is_empty() {
ui.label("This drawing has no named viewport configurations.");
}
ui.horizontal_top(|ui| {
egui::ScrollArea::vertical().id_salt("namedvp_list").max_height(120.0).min_scrolled_width(180.0).show(ui, |ui| {
ui.set_min_width(180.0);
for (name, _) in &configs {
let r = ui.selectable_label(sel.as_deref() == Some(name.as_str()), name);
if r.clicked() {
sel = Some(name.clone());
}
if r.double_clicked() {
sel = Some(name.clone());
restore = true;
}
}
});
let tiles = sel.as_ref().and_then(|s| configs.iter().find(|c| c.0 == *s)).map(|c| c.1.as_slice()).unwrap_or_default();
preview(ui, tiles, &t);
});
ui.separator();
ui.horizontal(|ui| {
let hint = if model { "Show this configuration's viewports" } else { "Place this configuration's viewports in the layout" };
if ui.add_enabled(sel.is_some(), egui::Button::new("Restore")).on_hover_text(hint).clicked() {
restore = true;
}
if ui.add_enabled(sel.is_some(), egui::Button::new("Delete")).on_hover_text("Delete the saved configuration").clicked()
&& let Some(n) = &sel
{
action = Some(("vports.named", json!({ "name": n, "delete": true })));
}
if ui.button("Close").clicked() {
close = true;
}
});
});
if let Some(n) = sel.clone() {
set_temp(ctx, "namedvp_sel", n);
}
if close {
*open = false;
}
let Some(name) = sel.filter(|_| restore) else { return action };
*open = false;
if model {
return Some(("vports.named", json!({ "name": name })));
}
// The viewports' corners are picked in the layout, as for MVIEW Restore.
app.start("mview");
if app.session.running.as_ref().is_some_and(|r| r.id == "mview") {
for i in [Input::Keyword("Restore".into()), Input::Text(name)] {
if let Err(e) = app.session.input(i) {
app.session.echo(e.to_string());
break;
}
}
}
None
}
+1 -1
View File
@@ -231,7 +231,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
ui.add_space(4.0);
// Font
let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone());
egui::ComboBox::from_id_salt("mtext_font")
craft_ui::ComboBox::from_id_salt("mtext_font")
.width(120.0)
.selected_text(shown.clone())
.show_ui(ui, |ui| {
+5 -5
View File
@@ -141,7 +141,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
grid(ui, "options_display", |ui| {
let theme_caption = ui.label(crate::tl!("Interface theme"));
let mut theme = app.ui.theme;
let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| {
let theme_combo = craft_ui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| {
for t in ThemePref::ALL {
ui.selectable_value(&mut theme, t, theme_label(t));
}
@@ -154,7 +154,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
let animations_caption = ui.label(crate::tl!("Animation effects"));
let mut animations = app.ui.animations;
let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| {
let animations_combo = craft_ui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| {
for a in AnimationPref::ALL {
ui.selectable_value(&mut animations, a, animations_label(a));
}
@@ -168,7 +168,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
let scale_caption = ui.label(crate::tl!("Interface scale"));
let scale = app.options.interface_scale;
let percent = |s: f32| format!("{} %", (s * 100.0).round());
let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| {
let scale_combo = craft_ui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| {
for n in [75u16, 100, 125, 150, 175, 200, 250, 300] {
let s = f32::from(n) / 100.0;
if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 {
@@ -252,7 +252,7 @@ fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map<String, V
let version_caption = ui.label(crate::tl!("Default save version"));
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
let mut version = current;
let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
let version_combo = craft_ui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
for v in crate::SaveVersion::ALL {
ui.selectable_value(&mut version, v, v.year());
}
@@ -531,7 +531,7 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
grid(ui, "options_preferences", |ui| {
let language_caption = ui.label(crate::tl!("Interface language"));
let mut lang = app.ui.interface_language;
let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| {
let language_combo = craft_ui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| {
for p in crate::i18n::Preference::for_picker() {
ui.selectable_value(&mut lang, p, p.name());
}
+16
View File
@@ -727,6 +727,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
let tb_styles: Vec<String> = d.table_styles.iter().map(|l| l.name.clone()).collect();
let mut action: Option<(&str, Value)> = None;
let mut act = |c: &'static str, p: Value| action = Some((c, p));
// In the Block Editor, the edited block's name and description come first.
let edited = app.session.state().ok().and_then(|st| {
let be = st.block_edit.as_ref().filter(|be| !be.current)?;
let b = d.block(&be.name).or_else(|| be.host.doc.block(&be.name));
Some((be.name.clone(), b.map(|b| b.description.clone()).unwrap_or_default()))
});
if let Some((name, description)) = edited {
prop_row(ui, "Name", |ui| {
value_box(ui, &name, false, false).on_hover_text(crate::tl!("Read-only"));
});
prop_row(ui, "Description", |ui| {
if let Some(text) = edit_field(ui, ui.id().with("block_description"), description) {
act("blocks.description", json!({ "description": text }));
}
});
}
prop_row(ui, "Color", |ui| {
ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| {
for (n, c) in [
+2 -2
View File
@@ -305,7 +305,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
egui::Grid::new("qc-units-grid").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
ui.label(crate::tl!("Type"));
let kind_label = KINDS.get(st.kind).map_or("", |k| k.1);
egui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("qc-kind").selected_text(crate::i18n::t(kind_label)).show_ui(ui, |ui| {
for (i, (_, label)) in KINDS.iter().enumerate() {
if ui.selectable_label(st.kind == i, crate::i18n::t(label)).clicked() {
st.kind = i;
@@ -320,7 +320,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
ui.label(label);
let sel = if which == 0 { &mut st.from } else { &mut st.to };
let shown = list.get(*sel).map(|u| u.1.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(salt).selected_text(shown).show_ui(ui, |ui| {
for (i, (_, sym)) in list.iter().enumerate() {
if ui.selectable_label(*sel == i, sym).clicked() {
*sel = i;
+3 -3
View File
@@ -69,7 +69,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(crate::tl!("Plot style table"));
egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() {
sel = n.clone();
@@ -160,7 +160,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
action = set(&table.name, style("screening", json!(screen)));
}
let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string());
egui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() {
action = set(&table.name, style("linetype", Value::Null));
}
@@ -174,7 +174,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
.lineweight
.map(|w| format!("{:.2} mm", f64::from(w) / 100.0))
.unwrap_or_else(|| crate::tl!("Use object lineweight").to_string());
egui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() {
action = set(&table.name, style("lineweight", Value::Null));
}
+5 -5
View File
@@ -86,7 +86,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
.show(ctx, |ui| {
egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(crate::tl!("Apply to:"));
egui::ComboBox::from_id_salt("qs_apply")
craft_ui::ComboBox::from_id_salt("qs_apply")
.width(220.0)
.selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" }))
.show_ui(ui, |ui| {
@@ -107,7 +107,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
.collect()
})
.unwrap_or_default();
egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| {
ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple"));
for (n, c) in &types {
ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n)));
@@ -136,7 +136,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
ui.end_row();
ui.label(crate::tl!("Operator:"));
let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals");
egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| {
for (o, l) in OPERATORS {
ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l));
}
@@ -317,7 +317,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
}
ui.end_row();
ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim));
egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| {
for l in &layers {
if ui.selectable_label(*l == layer, l).clicked() {
set = Some(("layer".into(), json!(l)));
@@ -326,7 +326,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
});
ui.end_row();
ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim));
egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| {
for l in std::iter::once("ByLayer".to_string())
.chain(std::iter::once("ByBlock".to_string()))
.chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned())
@@ -0,0 +1,49 @@
//! Properties in the Block Editor: with nothing selected it shows the edited block's name and
//! description, and a description typed there is saved into the block by BSAVE.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Key, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{Queryable, by};
use serde_json::json;
/// Block "Door", described "Hinged door", open in the Block Editor.
fn palettes() -> Harness<'static, CadApp> {
let mut app = CadApp::new(Session::default(), Services::default());
let l = app.run("line", json!({ "points": [[0, 0], [2, 0]] })).unwrap();
app.run("block", json!({ "name": "Door", "base": [0, 0], "handles": l["handles"], "description": "Hinged door" })).unwrap();
app.run("bedit", json!({ "name": "Door" })).unwrap();
let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(&ui.ctx().clone());
cadcraft_ui_egui::palettes::right_palettes(app, ui);
},
app,
);
h.run_steps(4);
h
}
fn field<'h>(h: &'h Harness<'static, CadApp>, shown: &'h str) -> egui_kittest::Node<'h> {
h.get(by().role(egui::accesskit::Role::TextInput).value(shown))
}
#[test]
fn the_block_editor_shows_and_changes_the_block_description() {
let mut h = palettes();
field(&h, "Hinged door").focus();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, Key::A);
h.run_steps(1);
field(&h, "Hinged door").type_text("Sliding door");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
field(&h, "Sliding door");
let app = h.state_mut();
assert!(app.session.state().unwrap().block_unsaved());
app.run("bclose", json!({ "save": true })).unwrap();
assert_eq!(app.session.doc().unwrap().block("Door").unwrap().description, "Sliding door");
}
+2
View File
@@ -1,4 +1,5 @@
mod animation_effects;
mod block_description_palette;
mod chrome_geometry;
mod context_menu;
mod context_menus;
@@ -17,6 +18,7 @@ mod lisp_files;
mod measure_distance_snapping;
mod menubar_hover;
mod mspace_viewport_view;
mod named_viewports_dialog;
mod native_menu_text_keys;
mod native_windows;
mod options_dialog;
@@ -0,0 +1,74 @@
//! View > Viewports > Named Viewports... opens a dialog that lists the saved viewport
//! configurations: Restore shows one in the Model tab or starts placing it in a layout, and Delete
//! deletes it.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services, dialogs, menus};
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// A drawing with "Two" (two tiles side by side) saved, showing one view, and the dialog open.
fn dialog() -> Harness<'static, CadApp> {
let mut app = CadApp::new(Session::default(), Services::default());
app.run("line", json!({ "points": [[0, 0], [100, 50]] })).unwrap();
app.run("-vports", json!({ "option": "2", "arrangement": "vertical" })).unwrap();
app.run("-vports", json!({ "option": "save", "name": "Two" })).unwrap();
app.run("-vports", json!({ "option": "single" })).unwrap();
menus::activate(&mut app, "vports.named");
assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"));
let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(&ui.ctx().clone());
dialogs::show(app, ui.ctx());
},
app,
);
h.run_steps(3);
h
}
#[test]
fn restore_and_delete_in_the_model_tab() {
let mut h = dialog();
h.get_by_label("Current name: *Active");
h.get_by_label("Two").click();
h.run_steps(2);
h.get_by_label("Restore").click();
h.run_steps(3);
let app = h.state_mut();
assert_eq!(app.session.model_tiles().len(), 2);
assert_eq!(app.ui.dialog, None, "Restore closes the dialog");
menus::activate(app, "vports.named");
h.run_steps(3);
h.get_by_label("Current name: Two");
h.get_by_label("Delete").click();
h.run_steps(3);
let app = h.state_mut();
assert!(app.session.doc().unwrap().vports.iter().all(|c| c.name != "Two"));
assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"), "Delete keeps the dialog open");
h.get_by_label("This drawing has no named viewport configurations.");
}
#[test]
fn restore_in_a_layout_asks_where_the_viewports_go() {
let mut h = dialog();
let app = h.state_mut();
app.ui.dialog = None;
app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
menus::activate(app, "vports.named");
h.run_steps(3);
h.get_by_label("Two").click();
h.run_steps(2);
h.get_by_label("Restore").click();
h.run_steps(3);
let app = h.state_mut();
assert!(app.session.prompt_text().contains("Specify first corner"), "{}", app.session.prompt_text());
let before = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count();
app.cmdline("");
assert!(app.session.running.is_none());
let after = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count();
assert_eq!(after, before + 2, "Enter fits the two viewports to the printable area");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
@@ -108,7 +108,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
| Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) |
|---|---|---|---|
| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 |
| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552); LAYOUT SAveas writes one layout and the named objects it uses as a `.dwt` (`layout.saveas`) | 0–1 |
| DWS standards | open, save, CHECKSTANDARDS | no | 6–10 |
| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 |
| PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 |
@@ -140,6 +140,7 @@ our output in AutoCAD black-box.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use |
| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
| 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
+5 -4
View File
@@ -1,6 +1,6 @@
# Where CADCraft falls short of AutoCAD
> **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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected) · **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
@@ -37,12 +37,12 @@ do them first.
| F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 |
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 |
| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 |
| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 |
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
| 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 |
| 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) | `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 (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 |
@@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode
| U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 |
| U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 |
| U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 |
| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 |
| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 |
| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 |
| U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 |
@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles |
| 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 |
+3 -5
View File
@@ -4,19 +4,19 @@
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: 296 / 491 items (60%)** · 441 registered commands.
**Menu breadth: 297 / 491 items (60%)** · 442 registered commands.
| Menu | Items | Covered | % |
|---|---|---|---|
| File | 22 | 13 | 59% |
| Edit | 13 | 12 | 92% |
| View | 75 | 36 | 48% |
| View | 75 | 37 | 49% |
| Insert | 11 | 5 | 45% |
| Format | 27 | 26 | 96% |
| Tools | 85 | 66 | 78% |
| Draw | 87 | 55 | 63% |
| Dimension | 28 | 28 | 100% |
| Modify | 113 | 52 | 46% |
| Modify | 113 | 53 | 47% |
| Window | 22 | 3 | 14% |
| Help | 8 | 0 | 0% |
@@ -37,7 +37,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- View > Orbit > Free Orbit
- View > Camera > Adjust Distance
- View > Camera > Swivel
- View > Viewports > Named Viewports...
- View > 3D Views > Viewpoint
- View > 3D Views > Plan View > Current UCS
- View > 3D Views > Plan View > World UCS
@@ -134,7 +133,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Modify > Object > Image > Quality
- Modify > Object > Image > Transparency
- Modify > Object > Image > Frame
- Modify > Object > Block Description...
- Modify > Clip > Image
- Modify > Clip > Xref
- Modify > 3D Operations > 3D Move
-3
View File
@@ -5,9 +5,6 @@ edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
chrono = { workspace = true }