Compare commits

...
31 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 4b4ec8043e Release 0.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:59:24 -05:00
Dylan M. Taylor 7400119120 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:06 -05:00
258c89fbb0 Window opens centred; the Chinese menus have no English entries left (#1129) (#2510)
* Window opens centred; the Chinese menus have no English entries left (#1129)

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

* Add GULERTOLGA to contributors

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

---------

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

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

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

* Vietnamese: the terminology Vietnamese AutoCAD users speak

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* wblock_units: each WBLOCK call writes into its own TempDir

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

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

---------

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

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

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

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

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

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

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

---------

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

Fixes #2468

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

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

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

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

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

---------

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:44:27 -04:00
aiongg 11ce7eb52c updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:07 +00:00
aiongg 9b32882065 docs: tidy wording 2026-10-31 04:25:44 +00:00
aiongg ee715f4636 docs: tidy agent, contributor and asset docs 2026-10-31 04:17:25 +00:00
80 changed files with 5308 additions and 305 deletions
+1
View File
@@ -62,6 +62,7 @@ jobs:
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+4 -9
View File
@@ -2,8 +2,6 @@
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
@@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
@@ -47,25 +45,22 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+9 -8
View File
@@ -48,14 +48,14 @@ Generated-in-code assets are original and have no file to list:
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
## Interface translations
@@ -77,3 +77,4 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
+2 -4
View File
@@ -2,8 +2,6 @@
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
@@ -12,7 +10,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
@@ -46,7 +44,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
Generated
+25 -25
View File
@@ -268,7 +268,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -643,7 +643,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -659,14 +659,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -676,7 +676,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,14 +687,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"encoding_rs",
"log",
@@ -703,7 +703,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -724,7 +724,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
@@ -734,14 +734,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -762,7 +762,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -775,7 +775,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -789,7 +789,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -815,7 +815,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -1191,7 +1191,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1643,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3435,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4030,7 +4030,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4087,7 +4087,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4562,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5471,7 +5471,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5894,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.16.0"
dependencies = [
"chrono",
"serde_json",
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.15.1"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -66,7 +66,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+9 -1
View File
@@ -377,7 +377,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+3 -3
View File
@@ -125,8 +125,8 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
@@ -144,7 +144,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
## Shared UI workspace integration
+35 -17
View File
@@ -194,6 +194,31 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters
}
/// The main window: its size, title bar, placement and rendering needs.
fn window_options() -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
// Wayland compositors place windows themselves.
centered: true,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
}
}
const APP_ID: &str = "ai.storyteller.cadcraft";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
@@ -256,22 +281,7 @@ fn main() -> eframe::Result {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
..window_options()
};
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
@@ -383,7 +393,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{filters_for_save, follow_desktop};
use super::{filters_for_save, follow_desktop, window_options};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -401,6 +411,14 @@ mod tests {
}
}
/// #1129: the window opens centred, not cascaded from the top-left corner.
#[test]
fn the_window_opens_centred() {
let o = window_options();
assert!(o.centered, "centred on launch");
assert!(!o.persist_window, "no saved position to restore instead");
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
+5 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -99,3 +96,6 @@ sync_github_name = false
[people.dylanmtaylor]
sync_github_name = true
[people.GULERTOLGA]
sync_github_name = true
+85
View File
@@ -465,6 +465,91 @@ impl EntityKind {
}
}
/// Scale every height of the object by `f` about the height `base_z`: the Z of its points,
/// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a
/// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform`
/// already scales (text and helix heights, an insert's Z scale) are left alone; splines,
/// regions and wipeouts lie in the XY plane.
pub fn scale_heights(&mut self, base_z: f64, f: f64) {
let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f;
let z = |p: &mut Vec3| scale(&mut p.z);
let text_z = |t: &mut Text| {
z(&mut t.insert);
if let Some(a) = &mut t.align_pt {
z(a);
}
};
let attribs_z = |attribs: &mut Vec<Attrib>| {
for a in attribs {
text_z(&mut a.text);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
};
match self {
EntityKind::Line(l) => {
z(&mut l.a);
z(&mut l.b);
}
EntityKind::Point(p) => z(&mut p.p),
EntityKind::Circle(c) => z(&mut c.center),
EntityKind::Arc(a) => z(&mut a.center),
EntityKind::Ellipse(e) => z(&mut e.center),
EntityKind::LwPolyline(p) => scale(&mut p.elevation),
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z),
EntityKind::Helix(h) => z(&mut h.center),
EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base),
EntityKind::Text(t) => text_z(t),
EntityKind::AttDef(a) => {
text_z(&mut a.text);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
EntityKind::MText(t) => z(&mut t.insert),
EntityKind::Insert(i) => {
z(&mut i.insert);
attribs_z(&mut i.attribs);
}
EntityKind::Dimension(d) => {
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
z(p);
}
if let Some(j) = &mut d.marks.jog_line {
z(j);
}
if let Some(j) = &mut d.marks.jogged {
z(&mut j.center);
z(&mut j.jog);
}
d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at));
d.block = None;
}
EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)),
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
EntityKind::MLeader(ml) => {
ml.leaders.iter_mut().flatten().for_each(z);
z(&mut ml.landing);
if let Some(t) = &mut ml.text {
z(&mut t.insert);
}
if let Some(b) = &mut ml.block {
z(&mut b.insert);
attribs_z(&mut b.attribs);
}
}
EntityKind::Hatch(h) => scale(&mut h.elevation),
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
EntityKind::Viewport(v) => z(&mut v.center),
EntityKind::Image(i) => z(&mut i.insert),
EntityKind::Table(t) => z(&mut t.insert),
EntityKind::Tolerance(t) => z(&mut t.insert),
EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
}
}
/// Grip points (editable defining points), in display order.
pub fn grips(&self) -> Vec<Vec2> {
// A broken table has a grip for each part.
+7
View File
@@ -416,6 +416,13 @@ mod native {
writes
}
/// Whether a background write hasn't finished yet. Its file can exist before it finishes
/// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the
/// drawing is left out of planning.
pub fn writing(&self) -> bool {
self.running.iter().any(|(_, h)| !h.is_finished())
}
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
+6 -1
View File
@@ -1,4 +1,5 @@
//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`).
//! Array commands: ARRAY, ARRAYRECT, ARRAYPOLAR and the classic -ARRAY (ARRAYPATH lives in
//! `modify2`).
//!
//! Arrays are associative by default (ARRAYASSOCIATIVITY): the source objects move into an
//! anonymous source block and the array becomes one block reference to an anonymous array block
@@ -21,6 +22,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod classic;
mod edit;
/// Most preview entities drawn while the array is edited.
@@ -48,6 +50,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{handles?, center, count, angle? (degrees, default 360), rotate?: bool, rows? (1), rowSpacing?, rowElevation?, levels? (1), levelSpacing?, base? ([x, y], default the first object's centre), associative?: bool (default ARRAYASSOCIATIVITY, on)} → {handle (the array, when associative), created (copies)}",
)
.interactive(|_| Ok(Box::new(PolarM::default()))),
CommandSpec::new("-array", "Array", classic::run_classic)
.params("{handles?, type?: \"rectangular\" (default) | \"polar\", rows? (1), cols? (1), rowSpacing?, colSpacing?, center, count, angle? (degrees, default 360), rotate?: bool (default true), base? ([x, y], default the DXF group 10 point of the last object)} → {created}: separate copies, as the classic -ARRAY makes them")
.interactive(|_| Ok(Box::new(classic::ClassicM::default()))),
]
.into_iter()
.chain(edit::specs())
+255
View File
@@ -0,0 +1,255 @@
//! -ARRAY: the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
//! (accoreconsole, every copy read back with `entget`). It makes separate copies, never an
//! associative array:
//! - "Enter the type of array [Rectangular/Polar] <R>": the default is the type used last;
//! - Rectangular asks "Enter the number of rows (---) <1>" and "Enter the number of columns (|||)
//! <1>", then "Enter the distance between rows or specify unit cell (---)" only for more than one
//! row (two corners give both distances) and "Specify the distance between columns (|||)" only for
//! more than one column. The copy in row r and column c moves by (c·columns, r·rows): 2 rows and
//! 2 columns at −12.57 and −18.01 put the copies below and to the left;
//! - Polar asks "Specify center point of array or [Base]", "Enter the number of items in the
//! array", "Specify the angle to fill (+=ccw, -=cw) <360>" and "Rotate arrayed objects?
//! [Yes/No] <Y>". Items are 360/n apart around a full circle, else fill/(n − 1) apart (6 items in
//! 270°: 54°; −90° turns clockwise). Not rotated, each copy moves as the DXF group 10 point of the
//! object selected last turns about the centre (a polyline's first vertex, an arc's centre), or
//! as the Base point does.
use cadcraft_doc::Handle;
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::{bad, bool_or, f64_or, point_param, point_req, str_param, targets};
use super::{KEYWORDS, angle, check_size, copy_all, count, count_or, max_count, other, yes_no};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Select,
Kind,
Rows,
Columns,
RowDistance,
/// The unit cell's other corner, after the first one.
Corner(Vec2),
ColumnDistance,
Center,
Base,
Items,
Fill,
Rotate,
}
#[derive(Default)]
pub(super) struct ClassicM {
sel: SelectPhase,
objs: Vec<Handle>,
stage: Stage,
rows: u64,
columns: u64,
row_distance: f64,
center: Vec2,
base: Option<Vec2>,
items: u64,
fill: f64,
}
/// The moves of a rectangular array's copies (the objects themselves stay in row 0, column 0).
fn rect_mats(rows: u64, columns: u64, row_distance: f64, column_distance: f64) -> Vec<Mat3> {
(0..rows)
.flat_map(|r| (0..columns).map(move |c| (r, c)))
.filter(|rc| *rc != (0, 0))
.map(|(r, c)| Mat3::translate(Vec2::new(c as f64 * column_distance, r as f64 * row_distance)))
.collect()
}
/// The transforms of a polar array's copies: `items` around `center` filling `fill` degrees,
/// turned with it or moved as `base` turns.
fn polar_mats(center: Vec2, items: u64, fill: f64, rotate: bool, base: Vec2) -> Vec<Mat3> {
let full = (fill.abs() - 360.0).abs() < 1e-9;
let gaps = if full { items } else { items.saturating_sub(1) }.max(1);
let step = fill.to_radians() / gaps as f64;
(1..items)
.map(|k| {
let a = step * k as f64;
if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(base.rotate_about(center, a) - base) }
})
.collect()
}
/// The DXF group 10 point of the last of `hs`, which a polar array that doesn't rotate its copies
/// moves around the centre.
fn last_point(s: &Session, hs: &[Handle]) -> Option<Vec2> {
let d = s.doc().ok()?;
hs.last().and_then(|h| d.entity(*h)).and_then(|e| crate::session::group_10(d, e))
}
/// -ARRAY's JSON form.
pub(super) fn run_classic(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let polar = match str_param(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("rectangular") => false,
Some("polar") => true,
Some(_) => return Err(bad("-array", "type must be \"rectangular\" or \"polar\"")),
};
// The size is checked before the copies' transforms are made.
let mats = if polar {
let center = point_req("-array", p, "center")?;
let items = count_or("-array", p, "count", 1)?;
check_size(s, items, hs.len())?;
let base = point_param(p, "base").or_else(|| last_point(s, &hs)).unwrap_or(center);
polar_mats(center, items, f64_or(p, "angle", 360.0), bool_or(p, "rotate", true), base)
} else {
let rows = count_or("-array", p, "rows", 1)?;
let columns = count_or("-array", p, "cols", 1)?;
check_size(s, rows.saturating_mul(columns), hs.len())?;
rect_mats(rows, columns, f64_or(p, "rowSpacing", 0.0), f64_or(p, "colSpacing", 0.0))
};
let created = copy_all(s, &hs, mats.into_iter())?;
Ok(json!({ "created": created }))
}
impl ClassicM {
/// Make the copies.
fn finish(&mut self, s: &mut Session, mats: Vec<Mat3>) -> Result<Step> {
check_size(s, mats.len() as u64 + 1, self.objs.len())?;
copy_all(s, &self.objs, mats.into_iter())?;
s.set_selection(Vec::new());
Ok(Step::Done)
}
/// After the rows and columns: the distances still needed, or the copies.
fn after_counts(&mut self, s: &mut Session) -> Result<Step> {
if self.rows > 1 {
self.stage = Stage::RowDistance;
} else if self.columns > 1 {
self.stage = Stage::ColumnDistance;
} else {
return self.finish(s, Vec::new());
}
Ok(Step::Continue)
}
fn rect(&mut self, s: &mut Session, column_distance: f64) -> Result<Step> {
let mats = rect_mats(self.rows, self.columns, self.row_distance, column_distance);
self.finish(s, mats)
}
}
impl Interactive for ClassicM {
fn name(&self) -> &'static str {
"-ARRAY"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
self.stage = Stage::Kind;
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.stage {
Stage::Select => self.sel.prompt(),
Stage::Kind => Prompt::new("Enter the type of array", KEYWORDS)
.kw(&["Rectangular", "Polar"])
.default(if s.last_used.classic_array_polar { "P" } else { "R" }),
Stage::Rows => Prompt::new("Enter the number of rows (---)", Accept::NUMBER).default("1"),
Stage::Columns => Prompt::new("Enter the number of columns (|||)", Accept::NUMBER).default("1"),
Stage::RowDistance => Prompt::new("Enter the distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
Stage::Corner(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
Stage::ColumnDistance => Prompt::new("Specify the distance between columns (|||)", Accept::NUMBER),
Stage::Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base"]),
Stage::Base => Prompt::new("Specify base point of objects", Accept::POINT),
Stage::Items => Prompt::new("Enter the number of items in the array", Accept::NUMBER),
Stage::Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default("360"),
Stage::Rotate => Prompt::new("Rotate arrayed objects?", KEYWORDS).kw(&["Yes", "No"]).default("Y"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let max = max_count(s, 1, self.objs.len());
match (self.stage, i) {
(Stage::Select, i) => match self.sel.feed(s, &i)? {
SelOutcome::More => {}
SelOutcome::Done(hs) => {
self.objs = hs;
self.stage = Stage::Kind;
}
SelOutcome::Empty => return Ok(Step::Done),
},
(Stage::Kind, i) => {
let polar = match &i {
Input::Enter => s.last_used.classic_array_polar,
Input::Keyword(k) | Input::Text(k) => match k.trim().trim_start_matches('_').to_ascii_lowercase().chars().next() {
Some('p') => true,
Some('r') => false,
_ => return Err(other("Invalid option keyword.")),
},
_ => return Ok(Step::Continue),
};
s.last_used.classic_array_polar = polar;
self.stage = if polar { Stage::Center } else { Stage::Rows };
}
(Stage::Rows, i) => {
if let Some(n) = count(&i, 1, max)? {
self.rows = n;
self.stage = Stage::Columns;
}
}
// No more columns than MAXARRAY allows with the rows.
(Stage::Columns, i) => {
if let Some(n) = count(&i, 1, max_count(s, self.rows, self.objs.len()))? {
self.columns = n;
return self.after_counts(s);
}
}
(Stage::RowDistance, Input::Point(a)) => self.stage = Stage::Corner(a),
(Stage::RowDistance, Input::Text(t)) => {
self.row_distance = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
if self.columns <= 1 {
return self.rect(s, 0.0);
}
self.stage = Stage::ColumnDistance;
}
// A unit cell gives both distances.
(Stage::Corner(a), Input::Point(b)) => {
self.row_distance = b.y - a.y;
return self.rect(s, b.x - a.x);
}
(Stage::ColumnDistance, Input::Text(t)) => {
let d = number(&t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a distance."))?;
return self.rect(s, d);
}
(Stage::Center, Input::Point(c)) => {
self.center = c;
self.stage = Stage::Items;
}
(Stage::Center, Input::Keyword(k)) if k == "Base" => self.stage = Stage::Base,
(Stage::Base, Input::Point(b)) => {
self.base = Some(b);
self.stage = Stage::Center;
}
(Stage::Items, i @ Input::Text(_)) => {
if let Some(n) = count(&i, 1, max)? {
self.items = n;
self.stage = Stage::Fill;
}
}
(Stage::Fill, i) => {
if let Some(a) = angle(&i, 360.0, &s.angle_settings())? {
self.fill = a;
self.stage = Stage::Rotate;
}
}
(Stage::Rotate, i) => {
let Some(rotate) = yes_no(&i, true) else { return Err(other("Invalid option keyword.")) };
let base = self.base.or_else(|| last_point(s, &self.objs)).unwrap_or(self.center);
let mats = polar_mats(self.center, self.items, self.fill, rotate, base);
return self.finish(s, mats);
}
_ => {}
}
Ok(Step::Continue)
}
}
+55 -6
View File
@@ -365,10 +365,18 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let src = s.doc()?;
let mut d = cadcraft_doc::Drawing::new_imperial();
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
// objects and the block's base point for a block (`base` is then in block coordinates).
@@ -595,6 +603,8 @@ enum InsStage {
ZScale,
Rotation,
Attribs,
/// A Verify attribute asked again.
Verify,
}
#[derive(Default)]
@@ -615,8 +625,11 @@ struct InsertM {
/// Where the block was dropped to pick a Basepoint on it.
dropped: Option<Vec2>,
rotation: Option<f64>,
attdefs: Vec<(String, String, String)>,
/// The attributes asked for: tag, prompt, default value, and Verify (asked again).
attdefs: Vec<(String, String, String, bool)>,
values: serde_json::Map<String, Value>,
/// Verify attributes asked again so far.
verified: usize,
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
explode: bool,
/// The file waiting for the Redefine answer.
@@ -633,6 +646,17 @@ impl InsertM {
} else {
InsStage::Attribs
};
if self.stage == InsStage::Attribs && self.values.is_empty() && !self.attdefs.is_empty() {
s.echo("Enter attribute values");
}
// Then the Verify attributes are asked again, with the values typed.
if self.stage == InsStage::Attribs && self.values.len() >= self.attdefs.len() && self.verified < self.verifying().len() {
if self.verified == 0 {
s.echo("Verify attribute values");
}
self.stage = InsStage::Verify;
return Ok(Step::Continue);
}
if self.stage == InsStage::Attribs
&& self.values.len() >= self.attdefs.len()
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
@@ -646,20 +670,26 @@ impl InsertM {
Ok(Step::Continue)
}
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
/// The Verify attributes asked for.
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
self.attdefs.iter().filter(|a| a.3).collect()
}
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point. Constant
/// and Preset attributes take their values without asking, and so do all of them with ATTREQ 0.
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
let Some(b) = s.doc()?.block(name).cloned() else {
s.echo(format!("Block \"{name}\" not found."));
return Ok(());
};
self.attdefs = if self.explode {
self.attdefs = if self.explode || !s.settings.attreq {
Vec::new()
} else {
b.entities
.iter()
.filter_map(|e| {
if let EntityKind::AttDef(a) = &e.kind {
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
(!a.constant && !a.modes.preset).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone(), a.modes.verify))
} else {
None
}
@@ -744,9 +774,14 @@ impl Interactive for InsertM {
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
InsStage::Attribs => {
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
}
InsStage::Verify => {
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
let typed = self.values.get(&tag).and_then(Value::as_str).unwrap_or_default().to_string();
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(typed)
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
@@ -905,7 +940,7 @@ impl Interactive for InsertM {
return self.advance(s);
}
InsStage::Attribs => {
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let v = match i {
Input::Text(t) => t,
_ => default,
@@ -913,6 +948,20 @@ impl Interactive for InsertM {
self.values.insert(tag, Value::String(v));
return self.advance(s);
}
// Enter keeps the value typed.
InsStage::Verify => {
let tag = self.verifying().get(self.verified).map(|a| a.0.clone()).unwrap_or_default();
match i {
Input::Text(t) => {
self.values.insert(tag, Value::String(t));
}
Input::Enter => {}
// A click or anything else is no answer: the value is asked for again.
_ => return Ok(Step::Continue),
}
self.verified += 1;
return self.advance(s);
}
}
Ok(Step::Continue)
}
+74 -4
View File
@@ -2,6 +2,7 @@ use super::{
Accept, EntityKind, Input, Interactive, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, f64_or, lwpoly,
number, other, point_param, point_req, rect_with_corners, size_param,
};
use crate::last_used::RectangModes;
// =====================================================================================
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
@@ -115,11 +116,58 @@ pub(crate) struct RectM2 {
asking: Option<RAsk>,
}
/// A rectangle's rotation as AutoCAD 2024 shows it: degrees from 0 to 360, two decimals.
fn rotation_text(rotation: f64) -> String {
let t = format!("{:.2}", rotation.to_degrees().rem_euclid(360.0));
if t == "360.00" { "0.00".into() } else { t }
}
/// AutoCAD 2024's line listing the RECTANG modes that are set, `None` when none is ("Current
/// rectangle modes: Chamfer=2.2518 x 3.7663 Rotation=50.50"): distances in the drawing's
/// units, the rotation as [`rotation_text`] shows it.
fn modes_line(s: &Session, m: &RectangModes) -> Option<String> {
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
let d = |v: f64| crate::units::format_distance(v, u, p);
let mut parts = Vec::new();
if m.chamfer.0 > 0.0 || m.chamfer.1 > 0.0 {
parts.push(format!("Chamfer={} x {}", d(m.chamfer.0), d(m.chamfer.1)));
}
if m.fillet > 0.0 {
parts.push(format!("Fillet={}", d(m.fillet)));
}
if m.elevation != 0.0 {
parts.push(format!("Elevation={}", d(m.elevation)));
}
if m.thickness != 0.0 {
parts.push(format!("Thickness={}", d(m.thickness)));
}
if m.width > 0.0 {
parts.push(format!("Width={}", d(m.width)));
}
let rot = rotation_text(m.rotation);
if rot != "0.00" {
parts.push(format!("Rotation={rot}"));
}
(!parts.is_empty()).then(|| format!("Current rectangle modes: {}", parts.join(" ")))
}
impl RectM2 {
/// The modes the next rectangle starts from.
fn modes(&self) -> RectangModes {
RectangModes {
chamfer: self.chamfer,
fillet: self.fillet,
width: self.width,
elevation: self.elevation,
thickness: self.thickness,
rotation: self.rotation,
}
}
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
/// the centre when centre mode is on, then applies the requested thickness.
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
let Some(a) = self.first else { return Ok(Step::Cancel) };
s.last_used.rectang = self.modes();
if w.abs() < 1e-12 || h.abs() < 1e-12 {
return Err(other("The rectangle has no area."));
}
@@ -155,6 +203,20 @@ impl Interactive for RectM2 {
fn name(&self) -> &'static str {
"RECTANG"
}
/// Starts from the modes the last rectangle left, and lists the ones that are set.
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let m = s.last_used.rectang;
self.chamfer = m.chamfer;
self.fillet = m.fillet;
self.width = m.width;
self.elevation = m.elevation;
self.thickness = m.thickness;
self.rotation = m.rotation;
if let Some(line) = modes_line(s, &m) {
s.echo(line);
}
Ok(Step::Continue)
}
/// Asks for the pending option's value, or for the first and second points; in centre mode
/// the first prompt asks for the centre instead of a corner.
fn prompt(&self, _s: &Session) -> Prompt {
@@ -173,9 +235,9 @@ impl Interactive for RectM2 {
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
RAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER)
.kw(&["Pick points"])
.default(format!("{:.0}", self.rotation.to_degrees()))
.default(rotation_text(self.rotation))
.base_opt(self.first),
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
@@ -207,8 +269,14 @@ impl Interactive for RectM2 {
self.chamfer.1 = self.chamfer.0;
self.asking = Some(RAsk::Chamfer2);
}
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
RAsk::Chamfer2 => {
self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0);
self.fillet = 0.0;
}
RAsk::Fillet => {
self.fillet = val(&i, self.fillet)?.max(0.0);
self.chamfer = (0.0, 0.0);
}
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
@@ -260,6 +328,8 @@ impl Interactive for RectM2 {
}
}
}
// A mode is kept as soon as it is set, even if no rectangle follows.
s.last_used.rectang = self.modes();
return Ok(Step::Continue);
}
match i {
+2 -1
View File
@@ -996,7 +996,8 @@ fn rectang_options_command_line() {
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
let pts = rect_pts(&s);
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
// RECTANG keeps the rotation of the rectangle before (45 degrees), as AutoCAD does: back to 0.
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 r 0 4,4").unwrap();
match last(&s) {
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
k => panic!("{k:?}"),
+13 -2
View File
@@ -446,9 +446,20 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
}
}
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
/// The backup of the drawing file `path`, beside it. A DWG's is `<name>.bak`, as AutoCAD names it,
/// in the extension's letter case (`plan.DWG` to `plan.BAK`); any other drawing appends `.bak`
/// (`plan.dxf.bak`), so `plan.dwg` and `plan.dxf` in one folder never overwrite each other's backup.
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
path.with_extension("bak")
match path.extension().map(|e| e.to_string_lossy()) {
Some(e) if e.eq_ignore_ascii_case("dwg") => path.with_extension(
e.chars().zip("bak".chars()).map(|(d, b)| if d.is_ascii_uppercase() { b.to_ascii_uppercase() } else { b }).collect::<String>(),
),
_ => {
let mut name = path.as_os_str().to_os_string();
name.push(".bak");
name.into()
}
}
}
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
+1
View File
@@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
mod assoc;
pub(crate) use assoc::detach;
mod edit;
mod pick;
use super::has_doc;
+1 -1
View File
@@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool {
ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size)
}
pub(super) fn detach(h: &mut Hatch) {
pub(crate) fn detach(h: &mut Hatch) {
h.associative = false;
h.loops.iter_mut().for_each(|l| l.sources.clear());
}
+26 -36
View File
@@ -34,7 +34,9 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed:
let r = d.len();
let a0 = d.angle();
let step = cadcraft_geom::TAU / n as f64;
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 - step / 2.0) };
// Circumscribed: the picked point is an edge's midpoint and the first vertex is the end of
// that edge counter-clockwise from it, as AutoCAD 2024 numbers them.
let (r, a0) = if inscribed { (r, a0) } else { (r / (step / 2.0).cos(), a0 + step / 2.0) };
(0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect()
}
@@ -62,43 +64,31 @@ pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec<PolyVertex> {
pts.into_iter().map(PolyVertex::new).collect()
}
/// Rectangle vertices with fillet radius or chamfer distances at every corner.
/// Rectangle vertices from corner `a` to the opposite corner `b`, with a fillet radius or
/// chamfer distances at every corner, in AutoCAD 2024's order: from `a` along X to `b`'s X, then
/// `b`, then back along Y. A chamfer cuts the edge into each corner by its first distance and the
/// edge out of it by its second; each fillet arc starts on the edge into its corner. A fillet or
/// chamfer that doesn't fit leaves the corners square.
pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> Vec<PolyVertex> {
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
let w = x1 - x0;
let h = y1 - y0;
if fillet > 0.0 && fillet * 2.0 <= w.min(h) + 1e-12 {
let r = fillet;
let bul = cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2);
return vec![
PolyVertex::new(Vec2::new(x0 + r, y0)),
PolyVertex::with_bulge(Vec2::new(x1 - r, y0), bul),
PolyVertex::new(Vec2::new(x1, y0 + r)),
PolyVertex::with_bulge(Vec2::new(x1, y1 - r), bul),
PolyVertex::new(Vec2::new(x1 - r, y1)),
PolyVertex::with_bulge(Vec2::new(x0 + r, y1), bul),
PolyVertex::new(Vec2::new(x0, y1 - r)),
PolyVertex::with_bulge(Vec2::new(x0, y0 + r), bul),
];
let corners = [a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)];
let short = (b.x - a.x).abs().min((b.y - a.y).abs());
let (d_in, d_out, bulge) = if fillet > 0.0 && fillet * 2.0 <= short + 1e-12 {
// Counter-clockwise round the corners (a positive bulge) when `b` is up and right of `a`, or
// down and left.
let sign = if (b.x - a.x) * (b.y - a.y) >= 0.0 { 1.0 } else { -1.0 };
(fillet, fillet, sign * cadcraft_geom::arc_to_bulge(std::f64::consts::FRAC_PI_2))
} else if chamfer.0 > 0.0 && chamfer.1 > 0.0 && chamfer.0 + chamfer.1 <= short + 1e-12 {
(chamfer.0, chamfer.1, 0.0)
} else {
return corners.into_iter().map(PolyVertex::new).collect();
};
let mut out = Vec::with_capacity(8);
for (i, cur) in corners.iter().enumerate() {
let (Some(prev), Some(next)) = (corners.get((i + 3) % 4), corners.get((i + 1) % 4)) else { continue };
out.push(PolyVertex::with_bulge(*cur + (*prev - *cur).normalized() * d_in, bulge));
out.push(PolyVertex::new(*cur + (*next - *cur).normalized() * d_out));
}
let (c1, c2) = chamfer;
if c1 > 0.0 && c2 > 0.0 && c1 + c1 <= w + 1e-12 && c2 + c2 <= h + 1e-12 {
return [
Vec2::new(x0 + c1, y0),
Vec2::new(x1 - c1, y0),
Vec2::new(x1, y0 + c2),
Vec2::new(x1, y1 - c2),
Vec2::new(x1 - c1, y1),
Vec2::new(x0 + c1, y1),
Vec2::new(x0, y1 - c2),
Vec2::new(x0, y0 + c2),
]
.into_iter()
.map(PolyVertex::new)
.collect();
}
rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1))
out
}
// ---------- the user's support files ----------
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("scale", "Scale", run_scale)
.menu(&["Modify", "Scale"])
.alias(&["sc"])
.params("{handles?, base: [x,y], factor, copy?: bool}")
.params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))),
CommandSpec::new("mirror", "Mirror", run_mirror)
.menu(&["Modify", "Mirror"])
+25 -2
View File
@@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": nh.hex() }))
}
/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's
/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z).
fn keep_z(src: &EntityKind, k: &mut EntityKind) {
match (src, k) {
(EntityKind::Line(s), EntityKind::Line(l)) => {
l.a.z = s.a.z;
l.b.z = s.b.z;
}
(EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z,
(EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z,
(EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation,
(EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z,
(EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z,
_ => {}
}
}
/// Add the offset copy `k` of `src` (on the current layer instead of the source's when
/// `current_layer`), then erase the source when `erase` and its layer is not locked.
fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is
/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's
/// keeps it, as AutoCAD 2024 makes them.
fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
keep_z(&src.kind, &mut k);
let space = s.space();
let d = s.doc_mut()?;
let mut common = src.common.clone();
if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) {
common.thickness = 0.0;
}
if current_layer {
common.layer = d.header.str("CLAYER", "0");
}
+4 -4
View File
@@ -1,6 +1,6 @@
use super::break_join::join;
use super::explode::explode;
use super::transform::{erase, transform_entities};
use super::transform::{at_elevation, erase, scale_entities, transform_entities};
use super::{
Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number,
};
@@ -242,7 +242,7 @@ impl SelectThen {
let f = require_length(Some(f))?;
// The next SCALE offers this factor.
s.last_used.scale_factor = f;
transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?;
scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?;
s.set_selection(Vec::new());
Ok(Step::Done)
}
@@ -439,13 +439,13 @@ impl Interactive for SelectThen {
};
if f > 1e-12 && f.is_finite() {
s.last_used.scale_factor = f;
transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?;
scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?;
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
(Op::Mirror, 1, Input::Point(p)) => {
if p.near(self.pts[0], 1e-12) {
if p.near(self.pts[0], cadcraft_geom::EPS) {
return Err(EngineError::Other("Mirror line points must differ.".into()));
}
self.pts.push(p);
+34 -4
View File
@@ -1,8 +1,33 @@
use cadcraft_geom::Vec3;
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
// ---------------- core operations ----------------
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
edit_entities(s, hs, copy, |e| e.kind.transform(m))
}
/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY,
/// and every height of the objects about its Z, their thickness too.
pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result<Vec<Handle>> {
let m = Mat3::scale_about(base.xy(), f);
edit_entities(s, hs, copy, |e| {
e.kind.transform(&m);
e.kind.scale_heights(base.z, f);
e.common.thickness *= f;
})
}
/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one.
pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 {
let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite());
Vec3::new(p.x, p.y, z.unwrap_or(0.0))
}
/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object
/// on a locked layer is left alone unless it is copied. The handles edited or made.
fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result<Vec<Handle>> {
let d = s.doc_mut()?;
let mut out = Vec::new();
for h in hs {
@@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
let nh = d.new_handle();
let mut ne = Entity { handle: nh, ..e };
ne.kind.transform(m);
edit(&mut ne);
if let Some(st) = d.space_mut(&sp) {
st.push(ne);
out.push(nh);
@@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:
if locked {
continue;
}
d.modify_entity(*h, |e| e.kind.transform(m))?;
d.modify_entity(*h, &edit)?;
out.push(*h);
}
}
@@ -92,7 +117,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
if f <= 0.0 {
return Err(bad("scale", "factor must be positive"));
}
let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?;
// A Z given with the base point, as AutoCAD takes one; without one, the current elevation.
let base = match p.get("base").and_then(|b| b.get(2)) {
Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?,
None => at_elevation(s, base),
};
let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
@@ -100,7 +130,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let a = point_req("mirror", p, "p1")?;
let b = point_req("mirror", p, "p2")?;
if a.near(b, 1e-12) {
if a.near(b, cadcraft_geom::EPS) {
return Err(bad("mirror", "mirror line points coincide"));
}
let erase_src = bool_or(p, "erase", false);
+25
View File
@@ -1,7 +1,9 @@
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
//! RENAME.
mod change;
mod chprop;
mod dash_units;
mod matchprop;
mod rename;
@@ -23,6 +25,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
CommandSpec::new("change", "Change", change::run_change)
.alias(&["-ch"])
.params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)")
.interactive(|_| Ok(Box::new(change::ChangeM::new()))),
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
.menu(&["Modify", "Match Properties"])
.alias(&["ma", "painter"])
@@ -38,6 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"),
CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))),
CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"),
CommandSpec::new("-units", "Units", run_units).alias(&["-un"]).params("(as UNITS)").interactive(|_| Ok(Box::new(dash_units::DashUnits::default()))),
CommandSpec::new("thickness", "Thickness", run_thickness)
.menu(&["Format", "Thickness"])
.params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}")
@@ -1181,6 +1188,11 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?;
}
"linetype" | "ltype" => {
// Stored trimmed, as -RENAME types it: lookups compare names untrimmed.
let to = to.trim().to_string();
if !rename::valid_name(&to) {
return Err(bad("rename", format!("`{to}` is not a valid linetype name")));
}
let d = s.doc_mut()?;
if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) {
return Err(bad("rename", format!("the {from} linetype cannot be renamed")));
@@ -1246,6 +1258,19 @@ mod tests {
assert_eq!(col(&s), Color::ByBlock);
}
#[test]
fn rename_linetype_rejects_invalid_names() {
let mut s = Session::new();
s.execute("linetype", &json!({ "load": "DASHED" })).unwrap();
for to in ["", " ", "A;B", "A|B", "*X"] {
let r = s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": to }));
assert!(r.is_err(), "renamed DASHED to {to:?}: {r:?}");
assert!(s.doc().unwrap().linetype("DASHED").is_some());
}
s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": " DASH2 " })).unwrap();
assert!(s.doc().unwrap().linetype("DASH2").is_some());
}
#[test]
fn properties_set_rejects_unloaded_linetype() {
let mut s = Session::new();
+544
View File
@@ -0,0 +1,544 @@
//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does.
//!
//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked
//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set
//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change
//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the
//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole,
//! every object read back with `entget`):
//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives
//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or
//! plumb with the other one ((60,0)). Enter leaves lines alone;
//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify
//! new circle radius <no change>";
//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new
//! text insertion point <no change>"; then "Enter new text style", "Specify new height" (not
//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for
//! Aligned or Fit text, whose two points set it) and "Enter new text". An
//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in
//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE
//! command.`;
//! - a block reference is placed by the point, its attributes moving and turning with it, or asks
//! "Specify new block insertion point <no change>"; then "Specify new block rotation angle";
//! - the point places a circle, text or block reference only when it is the one object selected:
//! with others, "Change point ignored" comes before that object's questions, which are asked as
//! for Enter;
//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No
//! changeable object selected".
//!
//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points,
//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary
//! associativity removed."), texts, multiline texts and block references.
use std::collections::{HashSet, VecDeque};
use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::{bad, f64_req, point_req, targets};
use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// A question CHANGE asks about one object.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Ask {
/// Not a question: "Change point ignored" as the object comes up.
Ignored,
Radius,
TextPoint,
Style,
Height,
Rotation,
Value,
Tag,
AttPrompt,
Default,
BlockPoint,
BlockRotation,
}
enum Stage {
Select,
Point,
Properties(Box<ChpropM>),
/// The questions left, in order, each about one object.
Asking(VecDeque<(Handle, Ask)>),
}
pub(super) struct ChangeM {
select: SelectPhase,
handles: Vec<Handle>,
stage: Stage,
}
impl ChangeM {
pub(super) fn new() -> Self {
ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select }
}
/// Apply the change point `p` (`None`: Enter) and line up the questions it leaves.
fn change_point(&mut self, s: &mut Session, p: Option<Vec2>) -> Result<Step> {
let alone = self.handles.len() == 1;
let ortho = s.drafting_settings().orthomode;
let mut asks = VecDeque::new();
let mut changeable = false;
for &h in &self.handles {
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
if matches!(kind, EntityKind::Line(_)) {
if let Some(p) = p {
move_end(s, h, p, ortho)?;
changeable = true;
}
continue;
}
let Some(questions) = questions(&kind) else { continue };
changeable = true;
let placed = p.filter(|_| alone);
if p.is_some() && !alone {
asks.push_back((h, Ask::Ignored));
}
if let Some(q) = placed {
place(s, h, q)?;
}
// The point answers the first question: the radius or the insertion point.
asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a)));
}
if !changeable {
s.echo("No changeable object selected");
return Ok(Step::Done);
}
self.stage = Stage::Asking(asks);
self.advance(s)
}
/// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`]
/// says isn't asked. Done when nothing is left to ask.
fn advance(&mut self, s: &mut Session) -> Result<Step> {
let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) };
while let Some(&(h, ask)) = asks.front() {
match ask {
Ask::Ignored => s.echo("Change point ignored"),
a if skipped(s, h, a) => {}
_ => return Ok(Step::Continue),
}
asks.pop_front();
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
/// The questions about an object of `kind`, the first answered by the change point when it is
/// the one object; `None` when the change point leaves it alone.
fn questions(kind: &EntityKind) -> Option<&'static [Ask]> {
Some(match kind {
EntityKind::Circle(_) => &[Ask::Radius],
EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value],
EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default],
EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation],
_ => return None,
})
}
fn text_of(kind: &EntityKind) -> Option<&Text> {
match kind {
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
_ => None,
}
}
fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> {
match kind {
EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t),
_ => None,
}
}
/// Where a circle, text or block reference is measured from: its centre, the point it is placed by
/// (the alignment point of justified text) or its insertion point.
fn anchor(kind: &EntityKind) -> Option<Vec2> {
match kind {
EntityKind::Circle(c) => Some(c.center.xy()),
EntityKind::Insert(i) => Some(i.insert.xy()),
k => text_of(k).map(Text::justify_point),
}
}
/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed
/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it).
fn skipped(s: &Session, h: Handle, ask: Ask) -> bool {
let Ok(d) = s.doc() else { return false };
let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false };
match ask {
Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0),
Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit),
_ => false,
}
}
/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end.
fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> {
let settings = s.drafting_settings();
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Line(l) = &mut e.kind {
let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) };
let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p };
end.x = q.x;
end.y = q.y;
}
})?;
Ok(())
}
/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block
/// reference moves there (with its attributes).
fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> {
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Circle(c) = &mut e.kind {
let r = c.center.xy().dist(q);
if r > 0.0 && r.is_finite() {
c.radius = r;
}
} else if let Some(at) = anchor(&e.kind) {
e.kind.transform(&Mat3::translate(q - at));
}
})?;
Ok(())
}
/// Set a circle's radius or a text's height.
fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> {
s.doc_mut()?.modify_entity(h, |e| match &mut e.kind {
EntityKind::Circle(c) => c.radius = v,
k => {
if let Some(t) = text_of_mut(k) {
t.height = v;
}
}
})?;
Ok(())
}
/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its
/// insertion point.
fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> {
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &e.kind {
let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation);
e.kind.transform(&m);
} else if let Some(t) = text_of_mut(&mut e.kind) {
t.rotation = cadcraft_geom::norm_angle(angle);
}
})?;
Ok(())
}
/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again).
fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result<bool> {
let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO);
match (ask, i.clone()) {
// Enter keeps what there is.
(_, Input::Enter) => {}
(Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?,
(Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())),
(Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => {
let v = match &i {
Input::Point(q) => q.dist(at),
Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?,
_ => 0.0,
};
if !(v > 0.0 && v.is_finite()) {
return Err(EngineError::Other("Value must be positive and nonzero.".into()));
}
set_length(s, h, v)?;
}
(Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?,
(Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => {
let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?;
set_rotation(s, h, a)?;
}
(Ask::Style, Input::Text(t) | Input::Keyword(t)) => {
let name = t.trim();
let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else {
s.echo(format!("Style \"{name}\" not defined - use STYLE command."));
return Ok(false);
};
s.doc_mut()?.modify_entity(h, |e| {
if let Some(t) = text_of_mut(&mut e.kind) {
t.style = style;
}
})?;
}
(Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => {
s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) {
(EntityKind::AttDef(a), Ask::Tag) => a.tag = t,
(EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t,
(k, _) => {
if let Some(x) = text_of_mut(k) {
x.value = t;
}
}
})?;
}
_ => return Ok(false),
}
Ok(true)
}
/// The question `ask` about `h`.
fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt {
let d = s.doc().ok();
let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind);
let at = kind.and_then(anchor);
let text = kind.and_then(text_of);
// As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text
// at 0° as 270.00).
let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees());
match ask {
Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(),
Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"),
Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"),
Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()),
Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER)
.default(format!("{:.4}", text.map_or(0.0, |t| t.height)))
.base_opt(at)
.distance(),
Ask::Rotation => {
Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at)
}
Ask::BlockRotation => {
let r = match kind {
Some(EntityKind::Insert(i)) => i.rotation,
_ => 0.0,
};
Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at)
}
Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()),
Ask::Tag | Ask::AttPrompt => {
let (msg, v) = match (kind, ask) {
(Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()),
(Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()),
(_, Ask::Tag) => ("Enter new tag", String::new()),
_ => ("Enter new prompt", String::new()),
};
Prompt::new(msg, Accept::TEXT).default(v)
}
Ask::Ignored => Prompt::new("", Accept::TEXT),
}
}
/// The one z of `zs`, to rounding; `None` when they differ.
fn shared_z(zs: impl IntoIterator<Item = f64>) -> Option<f64> {
let mut zs = zs.into_iter();
let first = zs.next().unwrap_or(0.0);
zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first)
}
/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between
/// two heights), `None` for the kinds Elev leaves alone.
pub(super) fn elevation_of(kind: &EntityKind) -> Option<Option<f64>> {
match kind {
EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])),
EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))),
EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))),
EntityKind::Point(p) => Some(Some(p.p.z)),
EntityKind::Circle(c) => Some(Some(c.center.z)),
EntityKind::Arc(a) => Some(Some(a.center.z)),
EntityKind::Ellipse(e) => Some(Some(e.center.z)),
EntityKind::LwPolyline(p) => Some(Some(p.elevation)),
EntityKind::Hatch(h) => Some(Some(h.elevation)),
EntityKind::MText(t) => Some(Some(t.insert.z)),
EntityKind::Insert(i) => Some(Some(i.insert.z)),
k => text_of(k).map(|t| Some(t.insert.z)),
}
}
/// Move `text` up by `dz`.
fn raise_text(text: &mut Text, dz: f64) {
for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) {
p.z += dz;
}
}
fn set_elevation(kind: &mut EntityKind, z: f64) {
let set = |p: &mut Vec3| p.z = z;
match kind {
EntityKind::Line(l) => {
set(&mut l.a);
set(&mut l.b);
}
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set),
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set),
EntityKind::Point(p) => set(&mut p.p),
EntityKind::Circle(c) => set(&mut c.center),
EntityKind::Arc(a) => set(&mut a.center),
EntityKind::Ellipse(e) => set(&mut e.center),
EntityKind::LwPolyline(p) => p.elevation = z,
EntityKind::Hatch(h) => {
h.elevation = z;
crate::cmd::hatch::detach(h);
}
EntityKind::MText(t) => set(&mut t.insert),
// A block reference's attributes go up with it.
EntityKind::Insert(i) => {
let dz = z - i.insert.z;
set(&mut i.insert);
for a in &mut i.attribs {
raise_text(&mut a.text, dz);
if let Some(ml) = &mut a.multiline {
ml.mtext.insert.z += dz;
}
}
}
k => {
if let Some(t) = text_of_mut(k) {
let dz = z - t.insert.z;
raise_text(t, dz);
}
}
}
}
/// What [`elevate`] did.
pub(super) struct Elevated {
/// The objects whose elevation was set.
pub(super) set: Vec<Handle>,
/// Objects whose points differ in z, which keep theirs.
pub(super) differing: usize,
/// Associative hatches, which no longer are.
pub(super) detached: usize,
}
/// Set the elevation of the objects among `hs` whose points share one.
pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result<Elevated> {
let d = s.doc_mut()?;
let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 };
for &h in hs {
let Some(e) = d.entity(h) else { continue };
match elevation_of(&e.kind) {
Some(Some(_)) => {
r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative));
d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?;
r.set.push(h);
}
Some(None) => r.differing += 1,
None => {}
}
}
Ok(r)
}
/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or
/// changed besides.
pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> {
let r = elevate(s, hs, z)?;
if r.differing > 0 {
s.echo("Cannot change elevation of objects with differing Z coordinates.");
}
if r.detached > 0 {
s.echo("Hatch boundary associativity removed.");
}
Ok(())
}
/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles,
/// texts and block references it leaves alone because others were selected too), `elevation` as
/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`:
/// associative hatches that no longer are); objects on locked layers are left out (`locked`).
pub(super) fn run_change(s: &mut Session, p: &Value) -> Result<Value> {
// A point or an elevation that can't be read is refused, and so is a call with neither.
let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?;
let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?;
if point.is_none() && elevation.is_none() {
return Err(bad("change", "`point` or `elevation` is required"));
}
let hs = targets(s, p)?;
let locked = locked(s, &hs);
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0);
if let Some(q) = point {
let ortho = s.drafting_settings().orthomode;
for &h in &free {
let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
if matches!(kind, EntityKind::Line(_)) {
move_end(s, h, q, ortho)?;
changed.insert(h);
} else if questions(&kind).is_some() {
if free.len() == 1 {
place(s, h, q)?;
changed.insert(h);
} else {
ignored += 1;
}
}
}
}
if let Some(z) = elevation {
let r = elevate(s, &free, z)?;
changed.extend(r.set);
(differing, detached) = (r.differing, r.detached);
}
Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() }))
}
impl Interactive for ChangeM {
fn name(&self) -> &'static str {
"CHANGE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.select = begin_unlocked(s);
if self.select.done {
self.handles = self.select.picked.clone();
self.stage = Stage::Point;
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match &self.stage {
Stage::Select => self.select.prompt(),
Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]),
Stage::Properties(m) => m.prompt(s),
Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match &mut self.stage {
Stage::Select => {
return Ok(match feed_unlocked(&mut self.select, s, i)? {
SelOutcome::More => Step::Continue,
SelOutcome::Done(hs) => {
self.handles = hs;
self.stage = Stage::Point;
Step::Continue
}
SelOutcome::Empty => {
s.set_selection(Vec::new());
Step::Done
}
});
}
Stage::Properties(m) => return m.input(s, i),
Stage::Asking(asks) => {
let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) };
if !answer(s, h, ask, i)? {
return Ok(Step::Continue);
}
asks.pop_front();
return self.advance(s);
}
Stage::Point => {}
}
match i {
Input::Keyword(k) if k == "Properties" => {
self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone())));
Ok(Step::Continue)
}
Input::Point(p) => self.change_point(s, Some(p)),
Input::Enter => self.change_point(s, None),
_ => Err(EngineError::Other("Point or option keyword required.".into())),
}
}
}
+79 -25
View File
@@ -28,6 +28,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
/// CHANGE's Properties: CHPROP's list with Elev.
const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
/// What CHPROP asks for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Ask {
@@ -40,6 +43,7 @@ enum Ask {
Lineweight,
Thickness,
Transparency,
Elevation,
}
pub(super) struct ChpropM {
@@ -47,11 +51,35 @@ pub(super) struct ChpropM {
handles: Vec<Handle>,
/// `None` while objects are selected.
ask: Option<Ask>,
/// CHANGE's Properties: Elev is on the list.
change: bool,
/// The elevation typed at Elev, set when the command ends.
elevation: Option<f64>,
}
impl ChpropM {
pub(super) fn new() -> Self {
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None }
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None }
}
/// CHANGE's Properties option on `handles`.
pub(super) fn change_properties(handles: Vec<Handle>) -> Self {
ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None }
}
fn keywords(&self) -> &'static [&'static str] {
if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES }
}
/// The selection's common elevation, or "varies" (as a line between two heights does alone).
fn common_elevation(&self, s: &Session) -> String {
let Ok(d) = s.doc() else { return String::new() };
let zs: Vec<Option<f64>> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect();
match zs.first() {
None => format!("{:.4}", 0.0),
Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"),
Some(_) => "varies".into(),
}
}
/// The selection's common value of a property, or "varies".
@@ -151,6 +179,13 @@ impl ChpropM {
return Ok(false);
}
}
Ask::Elevation => match number(word).filter(|v| v.is_finite()) {
Some(z) => self.elevation = Some(z),
None => {
s.echo("Requires a number.");
return Ok(false);
}
},
Ask::Property => {}
}
Ok(true)
@@ -158,7 +193,7 @@ impl ChpropM {
}
/// The objects among `hs` on locked layers.
fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
hs.iter().copied().filter(|h| is_locked(s, *h)).collect()
}
@@ -170,6 +205,38 @@ fn say_locked(s: &mut Session, n: usize) {
}
}
/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and
/// saying how many); `done` when some of it is left.
pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase {
let mut select = SelectPhase::begin(s);
if select.done {
let locked = locked(s, &select.picked);
if !locked.is_empty() {
select.picked.retain(|h| !locked.contains(h));
say_locked(s, locked.len());
s.set_selection(select.picked.clone());
// Nothing left of the pickfirst selection: ask for objects instead.
select.done = !select.picked.is_empty();
}
}
select
}
/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after
/// what was found.
pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result<SelOutcome> {
let (i, n) = match i {
Input::Pick(hs) if !select.removing => {
let locked = locked(s, &hs);
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
}
i => (i, 0),
};
let outcome = select.feed(s, &i)?;
say_locked(s, n);
Ok(outcome)
}
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
/// command leaves out too; "locked" says how many were.
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
@@ -242,17 +309,7 @@ impl Interactive for ChpropM {
"CHPROP"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.select = SelectPhase::begin(s);
if self.select.done {
let locked = locked(s, &self.select.picked);
if !locked.is_empty() {
self.select.picked.retain(|h| !locked.contains(h));
say_locked(s, locked.len());
s.set_selection(self.select.picked.clone());
// Nothing left of the pickfirst selection: ask for objects instead.
self.select.done = !self.select.picked.is_empty();
}
}
self.select = begin_unlocked(s);
if self.select.done {
self.handles = self.select.picked.clone();
self.ask = Some(Ask::Property);
@@ -262,7 +319,7 @@ impl Interactive for ChpropM {
fn prompt(&self, s: &Session) -> Prompt {
let Some(ask) = self.ask else { return self.select.prompt() };
match ask {
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES),
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(self.keywords()),
Ask::Color => {
Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase()))
}
@@ -275,21 +332,13 @@ impl Interactive for ChpropM {
Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))),
Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))),
Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())),
Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(ask) = self.ask else {
// Objects on locked layers are left out, and said after what was found.
let (i, n) = match i {
Input::Pick(hs) if !self.select.removing => {
let locked = locked(s, &hs);
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
}
i => (i, 0),
};
let outcome = self.select.feed(s, &i)?;
say_locked(s, n);
return Ok(match outcome {
return Ok(match feed_unlocked(&mut self.select, s, i)? {
SelOutcome::More => Step::Continue,
SelOutcome::Done(hs) => {
self.handles = hs;
@@ -304,6 +353,10 @@ impl Interactive for ChpropM {
};
match (ask, i) {
(Ask::Property, Input::Enter) => {
// CHANGE sets the elevation as it ends.
if let Some(z) = self.elevation {
super::change::elevate_and_say(s, &self.handles, z)?;
}
s.set_selection(Vec::new());
return Ok(Step::Done);
}
@@ -315,12 +368,13 @@ impl Interactive for ChpropM {
"ltScale" => Ask::LtScale,
"LWeight" => Ask::Lineweight,
"Thickness" => Ask::Thickness,
"Elev" => Ask::Elevation,
_ => Ask::Transparency,
});
}
(Ask::Property, Input::Text(t)) => {
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
let n = self.keywords().iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." });
}
(Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor),
+220
View File
@@ -0,0 +1,220 @@
//! -UNITS: the drawing's units at the command line, with AutoCAD 2024's prompts and listings.
use std::f64::consts::TAU;
use crate::units::{AngleSettings, format_angle};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
const REPORT_FORMATS: [&str; 10] = [
"Report formats: (Examples)",
" 1. Scientific 1.55E+01",
" 2. Decimal 15.50",
" 3. Engineering 1'-3.50\"",
" 4. Architectural 1'-3 1/2\"",
" 5. Fractional 15 1/2",
"With the exception of Engineering and Architectural formats,",
"these formats can be used with any basic unit of measurement.",
"For example, Decimal mode is perfect for metric units as well",
"as decimal English units.",
];
const ANGLE_SYSTEMS: [&str; 6] = [
"Systems of angle measure: (Examples)",
" 1. Decimal degrees 45.0000",
" 2. Degrees/minutes/seconds 45d0'0\"",
" 3. Grads 50.0000g",
" 4. Radians 0.7854r",
" 5. Surveyor's units N 45d0'0\" E",
];
const DENOMINATOR: &str = "Enter denominator of smallest fraction to display";
/// The drawing's units, as -UNITS shows and asks them.
struct Units {
lunits: i64,
luprec: i64,
aunits: i64,
auprec: i64,
angbase: f64,
clockwise: bool,
}
impl Units {
fn of(s: &Session) -> Units {
let Ok(d) = s.doc() else { return Units { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, clockwise: false } };
let h = &d.header;
let angbase = h.f64("ANGBASE", 0.0);
Units {
lunits: h.i64("LUNITS", 2).clamp(1, 5),
luprec: h.i64("LUPREC", 4).clamp(0, 8),
aunits: h.i64("AUNITS", 0).clamp(0, 4),
auprec: h.i64("AUPREC", 0).clamp(0, 8),
angbase: if angbase.is_finite() { angbase } else { 0.0 },
clockwise: h.i64("ANGDIR", 0) != 0,
}
}
/// Architectural and fractional units give their precision as a fraction's denominator.
fn fractional(&self) -> bool {
matches!(self.lunits, 4 | 5)
}
/// Direction `a` (radians counterclockwise from +X) in the angle format, measured as the
/// drawing measures angles but from +X: -UNITS shows north as 270 when angles turn clockwise.
fn direction_text(&self, a: f64) -> String {
format_angle(if self.clockwise { -a } else { a }, self.aunits, self.auprec)
}
/// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's
/// units N, E, S or W), measured from +X as the drawing measures angles.
fn direction(&self, t: &str) -> Option<f64> {
let cardinal = match t.trim().to_ascii_lowercase().as_str() {
"e" if self.aunits == 4 => Some(0.0),
"n" if self.aunits == 4 => Some(90f64.to_radians()),
"w" if self.aunits == 4 => Some(180f64.to_radians()),
"s" if self.aunits == 4 => Some(270f64.to_radians()),
_ => None,
};
let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?;
let a = if self.clockwise { -a } else { a };
a.is_finite().then(|| a.rem_euclid(TAU))
}
}
/// An integer answer in `lo..=hi`, or AutoCAD's message for one that isn't.
fn integer(t: &str, lo: i64, hi: i64) -> Result<i64> {
let v = t.trim().parse::<i64>().map_err(|_| EngineError::Other("Requires an integer value.".into()))?;
if (lo..=hi).contains(&v) { Ok(v) } else { Err(EngineError::Other(format!("Requires an integer between {lo} and {hi}."))) }
}
#[derive(Clone, Copy, Default)]
enum Ask {
#[default]
LengthFormat,
LengthPrecision,
AngleFormat,
AnglePrecision,
Direction,
Clockwise,
}
/// -UNITS: the length format and precision, the angle format and precision, the direction of
/// angle 0 and whether angles turn clockwise, asked in that order. As in AutoCAD, each answer is
/// kept as it is given (Esc keeps the ones before it), and choosing another format resets its
/// precision to 4, the precision then offered.
#[derive(Default)]
pub(super) struct DashUnits {
ask: Ask,
}
impl Interactive for DashUnits {
fn name(&self) -> &'static str {
"-UNITS"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
for l in REPORT_FORMATS {
s.echo(l);
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
let u = Units::of(s);
match self.ask {
Ask::LengthFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default(u.lunits.to_string()),
Ask::LengthPrecision if u.fractional() => {
Prompt::new("(1, 2, 4, 8, 16, 32, 64, 128, or 256)", Accept::NUMBER).default((1_i64 << u.luprec).to_string())
}
Ask::LengthPrecision => {
Prompt::new("Enter number of digits to right of decimal point (0 to 8)", Accept::NUMBER).default(u.luprec.to_string())
}
Ask::AngleFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default((u.aunits + 1).to_string()),
Ask::AnglePrecision => {
Prompt::new("Enter number of fractional places for display of angles (0 to 8)", Accept::NUMBER).default(u.auprec.to_string())
}
Ask::Direction => {
Prompt::new(format!("Enter direction for angle {}", u.direction_text(0.0)), Accept::TEXT).default(u.direction_text(u.angbase))
}
Ask::Clockwise => Prompt::new("Measure angles clockwise?", Accept::TEXT).kw(&["Yes", "No"]).default(if u.clockwise { "Y" } else { "N" }),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let u = Units::of(s);
let text = match &i {
Input::Text(t) => Some(t.as_str()),
Input::Enter => None,
Input::Keyword(k) => Some(k.as_str()),
_ => return Ok(Step::Continue),
};
let h = &mut s.doc_mut()?.header;
match self.ask {
Ask::LengthFormat => {
let l = text.map_or(Ok(u.lunits), |t| integer(t, 1, 5))?;
if l != u.lunits {
h.set_i64("LUNITS", l);
h.set_i64("LUPREC", 4);
}
self.ask = Ask::LengthPrecision;
if matches!(l, 4 | 5) {
s.echo(DENOMINATOR);
}
}
Ask::LengthPrecision => {
let prec = if u.fractional() {
let den = text.map_or(Some(1 << u.luprec), |t| t.trim().parse::<i64>().ok());
let Some(p) = den.and_then(|d| (0..=8).find(|p| 1_i64 << p == d)) else {
s.echo("Invalid choice.");
s.echo(DENOMINATOR);
return Ok(Step::Continue);
};
p
} else {
text.map_or(Ok(u.luprec), |t| integer(t, 0, 8))?
};
h.set_i64("LUPREC", prec);
self.ask = Ask::AngleFormat;
for l in ANGLE_SYSTEMS {
s.echo(l);
}
}
Ask::AngleFormat => {
let a = text.map_or(Ok(u.aunits + 1), |t| integer(t, 1, 5))? - 1;
if a != u.aunits {
h.set_i64("AUNITS", a);
h.set_i64("AUPREC", 4);
}
self.ask = Ask::AnglePrecision;
}
Ask::AnglePrecision => {
h.set_i64("AUPREC", text.map_or(Ok(u.auprec), |t| integer(t, 0, 8))?);
self.ask = Ask::Direction;
let u = Units::of(s);
s.echo(format!("Direction for angle {}:", u.direction_text(0.0)));
for (name, deg) in [("East 3", 0.0), ("North 12", 90.0), ("West 9", 180.0), ("South 6", 270.0)] {
s.echo(format!(" {name} o'clock = {}", u.direction_text(f64::to_radians(deg))));
}
}
Ask::Direction => {
if let Some(t) = text {
let a = u.direction(t).ok_or_else(|| EngineError::Other("Requires valid numeric angle or two points.".into()))?;
h.set_f64("ANGBASE", a);
}
self.ask = Ask::Clockwise;
}
Ask::Clockwise => {
let clockwise = match text.map(|t| t.trim().trim_start_matches('_').to_ascii_lowercase()) {
None => u.clockwise,
Some(t) if t == "y" || t == "yes" => true,
Some(t) if t == "n" || t == "no" => false,
Some(_) => return Err(EngineError::Other("Invalid option keyword.".into())),
};
h.set_i64("ANGDIR", i64::from(clockwise));
return Ok(Step::Done);
}
}
Ok(Step::Continue)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ use serde_json::json;
use super::super::bad;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
fn valid_name(n: &str) -> bool {
pub(super) fn valid_name(n: &str) -> bool {
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
}
+7
View File
@@ -333,6 +333,9 @@ fn call(name: &str, args: &[Val]) -> Result<Val, String> {
"min" | "max" => {
arity(1, MAX_ARGS)?;
let vs = (0..args.len()).map(n).collect::<Result<Vec<_>, _>>()?;
if vs.iter().any(|v| !v.is_finite()) {
return Err("result is not a finite number".into());
}
let f = if name == "min" { f64::min } else { f64::max };
Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0)))
}
@@ -597,6 +600,10 @@ mod tests {
assert!(ev("[1").is_err());
assert!(ev(&"[".repeat(5000)).is_err());
assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0)));
assert!(ev("min(sqrt(-1), 5)").is_err());
assert!(ev("max(5, ln(-1))").is_err());
assert!(ev("min(exp(1000), 5)").is_err());
assert!(ev("max(exp(1000), 5)").is_err());
assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002)));
assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err());
}
+20
View File
@@ -25,6 +25,10 @@ pub struct LastUsed {
pub mleader_block: String,
/// BREAKLINE's Block, Size and Extension.
pub breakline: cmd::BreaklineOptions,
/// -ARRAY's type: Polar instead of Rectangular.
pub classic_array_polar: bool,
/// RECTANG's chamfer, fillet, width, elevation, thickness and rotation.
pub rectang: RectangModes,
}
impl Default for LastUsed {
@@ -39,10 +43,26 @@ impl Default for LastUsed {
mleader: MLeaderOptions::default(),
mleader_block: String::new(),
breakline: cmd::BreaklineOptions::default(),
classic_array_polar: false,
rectang: RectangModes::default(),
}
}
}
/// The modes RECTANG keeps from one rectangle to the next, as AutoCAD 2024 does. Chamfer and
/// fillet exclude each other: setting one clears the other.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RectangModes {
/// First and second chamfer distances.
pub chamfer: (f64, f64),
pub fillet: f64,
pub width: f64,
pub elevation: f64,
pub thickness: f64,
/// Radians.
pub rotation: f64,
}
/// Which part of a multileader MLEADER places first.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeaderOrder {
+34 -11
View File
@@ -60,20 +60,43 @@ pub(super) fn atoi(a: &[Value]) -> R {
Ok(Value::Int(t.get(..end).and_then(|n| n.parse::<i64>().ok()).map(|n| n as i32).unwrap_or(0)))
}
/// C's `atof`: the longest number at the start; 0.0 when there is none.
/// C's `atof`: the number token at the start; 0.0 when there is none.
/// An overflowing token clamps to the largest finite value of its sign.
pub(super) fn atof(a: &[Value]) -> R {
arity(a, 1, 1)?;
let s = string(&a[0])?;
// The longest prefix (of the first 64 characters) that reads as a finite number.
let t: String = s.trim_start().chars().take(64).collect();
let ends: Vec<usize> = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect();
let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::<f64>().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e])));
Ok(Value::Real(v.unwrap_or(0.0)))
}
/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity").
fn p_is_word(p: &str) -> bool {
p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E'))
let b = s.trim_start().as_bytes();
let digits = |mut i: usize| {
while b.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
i
};
let mut i = usize::from(matches!(b.first(), Some(b'-' | b'+')));
let int_end = digits(i);
let mut end = int_end;
let mut mantissa = int_end > i;
if b.get(end) == Some(&b'.') {
let frac_end = digits(end + 1);
if mantissa || frac_end > end + 1 {
mantissa = true;
end = frac_end;
}
}
if mantissa && matches!(b.get(end), Some(b'e' | b'E')) {
i = end + 1 + usize::from(matches!(b.get(end + 1), Some(b'-' | b'+')));
let exp_end = digits(i);
if exp_end > i {
end = exp_end;
}
}
let v = if mantissa { b.get(..end).and_then(|t| std::str::from_utf8(t).ok()).and_then(|t| t.parse::<f64>().ok()) } else { None };
let v = match v {
Some(v) if v.is_finite() => v,
Some(v) if v.is_infinite() => f64::MAX.copysign(v),
_ => 0.0,
};
Ok(Value::Real(v))
}
pub(super) fn chr(a: &[Value]) -> R {
+9
View File
@@ -1039,3 +1039,12 @@ fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {
});
assert_eq!(r, Some(4.0));
}
#[test]
fn atof_out_of_range_exponent_reads_whole_token() {
let mut s = Session::new();
// Overflow must not fall back to a shorter prefix such as "1e40".
assert_eq!(ok(&mut s, "(> (atof \"1e400\") 1e300)"), "T");
assert_eq!(ok(&mut s, "(< (atof \"-1e400\") -1e300)"), "T");
assert_eq!(ok(&mut s, "(= (atof \"1e-400\") 0.0)"), "T");
}
+2
View File
@@ -13,3 +13,5 @@ mod files;
mod interactive;
mod picking;
mod zoom;
pub(crate) use picking::group_10;
+1 -1
View File
@@ -162,7 +162,7 @@ impl Session {
/// An object's DXF group 10 point: the first point of its entity list, or its first grip where
/// entity lists are not built in.
fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
pub(crate) fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() };
let (_, tags) = (codec.groups)(d, e);
let i = tags.iter().position(|t| t.code == 10)?;
+4
View File
@@ -127,6 +127,9 @@ pub struct Settings {
pub polardist: f64,
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
pub auditctl: bool,
/// ATTREQ: 0 inserts blocks with their attributes' default values instead of asking for them
/// (default: 1).
pub attreq: bool,
}
impl Default for Settings {
@@ -191,6 +194,7 @@ impl Default for Settings {
polarsnap: false,
polardist: 0.0,
auditctl: false,
attreq: true,
}
}
}
+61 -17
View File
@@ -94,6 +94,28 @@ fn hidden_layers(d: &Drawing) -> impl Iterator<Item = &str> {
d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str())
}
/// A copy of `tree` with `removed` taken out and `inserted` added, or `None` when the caller must
/// bulk-load instead. rstar 0.13 can panic ("This is a bug in rstar.") when inserting after removals
/// in a bulk-loaded tree (#2468), and picking is outside the command guard, so that panic would end
/// the app. The web build can't catch a panic, so it always bulk-loads.
fn patch_tree(tree: &RTree<Item>, removed: impl Iterator<Item = Item>, inserted: impl Iterator<Item = Item>) -> Option<RTree<Item>> {
if cfg!(target_arch = "wasm32") {
return None;
}
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut tree = tree.clone();
for it in removed {
tree.remove(&it);
}
for it in inserted {
tree.insert(it);
}
tree
}))
.map_err(|_| log::warn!("spatial index: rstar failed to patch the tree; rebuilding it"))
.ok()
}
impl SpatialIndex {
/// Build an index over a store (no caching).
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
@@ -127,23 +149,11 @@ impl SpatialIndex {
offset = offset.saturating_add(n);
}
// Patch the previous tree when most of it survives; otherwise bulk-load.
let tree = match prev {
Some(p) if kept.len() * 2 >= chunks.len().max(1) => {
let mut tree = p.tree.clone();
for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) {
for it in c.items() {
tree.remove(&it);
}
}
for c in &fresh {
for it in c.items() {
tree.insert(it);
}
}
tree
}
_ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()),
};
let patched = prev.filter(|_| kept.len() * 2 >= chunks.len().max(1)).and_then(|p| {
let removed = p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)).flat_map(|(_, c)| c.items());
patch_tree(&p.tree, removed, fresh.iter().flat_map(|c| c.items()))
});
let tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()));
let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect();
let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect();
SpatialIndex {
@@ -367,6 +377,40 @@ mod tests {
Some(Arc::new(SpatialIndex::build(d, &d.model)))
}
/// The edits of #2468, on rstar alone: its insert panics for seed 8. A failed patch falls back
/// to a bulk load, and every tree holds exactly the live items.
#[test]
fn rstar_patch_failures_fall_back_to_a_rebuild() {
fn item(r: &mut Rng, s: u32, i: u32) -> Item {
let (x, y, w, h) = (r.f() * 1000.0, r.f() * 1000.0, r.f() * 50.0, r.f() * 50.0);
GeomWithData::new(Rectangle::from_corners([x, y], [x + w, y + h]), (s, i))
}
for seed in 1..=10u64 {
let mut r = Rng(seed * 7919);
let n_chunks = 4 + (r.f() * 20.0) as u32;
let mut chunks: Vec<Vec<Item>> = (0..n_chunks)
.map(|s| {
let n = 1 + (r.f() * 60.0) as u32;
(0..n).map(|i| item(&mut r, s, i)).collect()
})
.collect();
let mut tree = RTree::bulk_load(chunks.iter().flatten().cloned().collect());
for serial in (n_chunks..).take(300) {
let c = (r.f() * chunks.len() as f64) as usize;
let n = (chunks[c].len() as i64 + if r.f() < 0.5 { -1 } else { 1 }).max(0) as u32;
let fresh: Vec<Item> = (0..n).map(|i| item(&mut r, serial, i)).collect();
let patched = patch_tree(&tree, chunks[c].iter().cloned(), fresh.iter().cloned());
chunks[c] = fresh;
tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flatten().cloned().collect()));
let mut held: Vec<(u32, u32)> = tree.iter().map(|it| it.data).collect();
let mut live: Vec<(u32, u32)> = chunks.iter().flatten().map(|it| it.data).collect();
held.sort_unstable();
live.sort_unstable();
assert_eq!(held, live, "seed {seed}, serial {serial}");
}
}
}
#[test]
fn pick_parity_with_scan() {
for seed in [3, 11, 97] {
+5 -2
View File
@@ -8,6 +8,7 @@ mod table;
const SESSION_VARS: &[&str] = &[
"AUDITCTL",
"ATTREQ",
"OSMODE",
"OSNAPHATCH",
"ANNOAUTOSCALE",
@@ -281,7 +282,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") };
let st = &mut s.settings;
match n.as_str() {
"OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff,
"OSMODE" => {
st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x| *x <= 0x7fff).ok_or_else(bad)? as u32
}
// The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour.
"CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?,
"OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?,
@@ -424,7 +427,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
// Keep the header type stable for known numeric vars.
match d.header.get(&n) {
Some(cadcraft_doc::HVal::Real(_)) => {
d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?);
d.header.set_f64(&n, val.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?);
return Ok(());
}
Some(cadcraft_doc::HVal::Int(_)) => {
+5 -2
View File
@@ -81,8 +81,11 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53,
/// On/off session settings: name, read, write.
type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool));
const SETTINGS: &[Toggle] =
&[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)];
const SETTINGS: &[Toggle] = &[
("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v),
("ATTREQ", |s| s.attreq, |s, v| s.attreq = v),
("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v),
];
fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> {
HEADER.iter().find(|(n, _, _)| *n == name)
+25
View File
@@ -653,6 +653,18 @@ fn setvar_rejects_out_of_range_units() {
assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9);
}
#[test]
fn setvar_rejects_out_of_range_osmode() {
let mut s = Session::new();
s.execute("setvar", &json!({"name": "OSMODE", "value": 4133})).unwrap();
for v in [4294967296u64, 65536, 32768] {
assert!(s.execute("setvar", &json!({"name": "OSMODE", "value": v})).is_err(), "{v}");
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(4133)), "{v}");
}
s.execute("setvar", &json!({"name": "OSMODE", "value": 0x7fff})).unwrap();
assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(0x7fff)));
}
#[test]
fn dynamic_input_pointer_settings() {
let mut s = Session::new();
@@ -1465,3 +1477,16 @@ fn array_counts_reject_invalid_values() {
}
s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap();
}
#[test]
fn setvar_rejects_non_finite_text_for_real_variables() {
let mut s = Session::new();
for name in ["LTSCALE", "TEXTSIZE", "SNAPANG"] {
let before = s.execute("getvar", &json!({"name": name})).unwrap();
for text in ["nan", "inf", "-inf"] {
let r = s.execute("setvar", &json!({"name": name, "value": text}));
assert!(r.is_err(), "SETVAR {name} {text} was accepted");
assert_eq!(s.execute("getvar", &json!({"name": name})).unwrap(), before);
}
}
}
+59 -1
View File
@@ -172,7 +172,16 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
} else {
("S", "E", deg - 270.0)
};
format!("{ns} {} {ew}", format_dms(ang, prec))
// A bearing shown as due north, south, east or west is that letter, as AutoCAD 2024
// shows it ("E" for 0), except to whole degrees ("N 90d E").
let dms = format_dms(ang, prec);
if prec > 0 && dms == format_dms(0.0, prec) {
ns.to_string()
} else if prec > 0 && dms == format_dms(90.0, prec) {
ew.to_string()
} else {
format!("{ns} {dms} {ew}")
}
}
_ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)),
}
@@ -332,6 +341,9 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
((num(g)? * 0.9).to_radians(), false)
} else if let Some(b) = bearing(&t) {
(b, true)
} else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() {
// A due bearing as surveyor's units show it: one letter.
(b, true)
} else if let Some(v) = num(&t) {
let a = match aunits {
2 => (v * 0.9).to_radians(),
@@ -345,6 +357,17 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
v.0.is_finite().then_some(v)
}
/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X.
fn cardinal(t: &str) -> Option<f64> {
match t {
"e" => Some(0.0),
"n" => Some(PI / 2.0),
"w" => Some(PI),
"s" => Some(1.5 * PI),
_ => None,
}
}
/// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X.
fn bearing(t: &str) -> Option<f64> {
let north = t.starts_with('n');
@@ -542,6 +565,41 @@ mod tests {
}
}
#[test]
fn surveyors_units_read_a_due_bearing_typed_as_one_letter() {
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`.
let a = AngleSettings { aunits: 4, angbase: 1.0, clockwise: true };
for (t, want) in [("E", 0.0), ("n", PI / 2.0), ("W", PI), ("s", 1.5 * PI)] {
let got = a.direction(t).unwrap();
assert!((got - want).abs() < 1e-12, "{t}: {got}");
}
// Other angle units don't take a bare letter.
assert_eq!(AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }.direction("E"), None);
}
#[test]
fn surveyors_units_show_a_due_bearing_as_one_letter() {
// AutoCAD 2024's ANGTOS in surveyor's units (measured): due east, north, west and south are
// one letter, also when the bearing only rounds to one; to whole degrees east stays a
// bearing.
let cases = [
(0.0, 0, "N 90d E"),
(0.0, 1, "E"),
(0.0, 2, "E"),
(0.0, 4, "E"),
(PI / 2.0, 1, "N"),
(PI, 4, "W"),
(3.0 * PI / 2.0, 4, "S"),
(0.00001, 2, "E"),
(1.0, 1, "N 32d42' E"),
(1.0, 3, "N 32d42'15\" E"),
(1.0, 7, "N 32d42'15.194\" E"),
];
for (angle, prec, expected) in cases {
assert_eq!(format_angle(angle, 4, prec), expected, "{angle} AUPREC {prec}");
}
}
#[test]
fn rounding_to_a_full_turn_wraps_to_zero() {
let almost_turn = 359.9999_f64.to_radians();
+8
View File
@@ -185,6 +185,14 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
// The file exists before the write finishes (the folder sync comes after the rename); until
// it has, the drawing is busy and the change drawn below would not be planned.
for _ in 0..500 {
if !a.writing() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
// While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH
// are system variables (process-wide, so set in this test only).
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10)));
+321
View File
@@ -0,0 +1,321 @@
//! CHANGE as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back
//! with `entget`).
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::doc::color::Color;
use serde_json::json;
/// The measured drawing, its objects named in LISP: lines `a` (0,0)–(50,0) and `b` (0,20)–(50,30)
/// on layer L1, circle `c` (centre (100,0), radius 10), text `t` "ABC" at (0,50) 2.5 high, circle
/// `e` ((200,0), 3), arc `r` ((150,0), 10, 0°–90°) and polyline `p` (0,100)–(50,100)–(50,120); then
/// `code`.
fn run(code: &str) -> Session {
// `entmake` and `entget` go through the DXF codec, as in the app.
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let mut s = Session::new();
s.doc_mut().unwrap().ensure_layer("L1");
for (v, made) in [
("a", "(0 . \"LINE\") (10 0.0 0.0 0.0) (11 50.0 0.0 0.0)"),
("b", "(0 . \"LINE\") (8 . \"L1\") (10 0.0 20.0 0.0) (11 50.0 30.0 0.0)"),
("c", "(0 . \"CIRCLE\") (10 100.0 0.0 0.0) (40 . 10.0)"),
("t", "(0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\")"),
("e", "(0 . \"CIRCLE\") (10 200.0 0.0 0.0) (40 . 3.0)"),
("r", "(0 . \"ARC\") (10 150.0 0.0 0.0) (40 . 10.0) (50 . 0.0) (51 . 1.5707963267948966)"),
("p", "(0 . \"LWPOLYLINE\") (90 . 3) (70 . 0) (10 0.0 100.0) (10 50.0 100.0) (10 50.0 120.0)"),
] {
s.cmdline(&format!("(setq {v} (progn (entmake '({made})) (entlast)))")).unwrap();
}
s.log.clear();
s.cmdline(&format!("(progn {code})")).unwrap();
s
}
/// The `i`th object drawn.
fn kind(s: &Session, i: usize) -> EntityKind {
s.doc().unwrap().model.iter().nth(i).unwrap().kind.clone()
}
fn line(s: &Session, i: usize) -> [[f64; 3]; 2] {
let EntityKind::Line(l) = kind(s, i) else { panic!("not a line") };
[[l.a.x, l.a.y, l.a.z], [l.b.x, l.b.y, l.b.z]]
}
fn radius(s: &Session, i: usize) -> f64 {
let EntityKind::Circle(c) = kind(s, i) else { panic!("not a circle") };
c.radius
}
fn text(s: &Session, i: usize) -> cadcraft_engine::doc::Text {
match kind(s, i) {
EntityKind::Text(t) => t,
EntityKind::AttDef(a) => a.text,
_ => panic!("not a text"),
}
}
fn said(s: &Session, line: &str) -> usize {
s.log.iter().filter(|l| l.as_str() == line).count()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn the_change_point_moves_the_nearer_end_of_each_line() {
let s = run("(command \"_.CHANGE\" a \"\" '(60.0 10.0))");
assert_eq!(line(&s, 0), [[0.0, 0.0, 0.0], [60.0, 10.0, 0.0]]);
let s = run("(command \"_.CHANGE\" a \"\" '(-5.0 5.0))");
assert_eq!(line(&s, 0), [[-5.0, 5.0, 0.0], [50.0, 0.0, 0.0]]);
let s = run("(command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
assert_eq!((line(&s, 0)[1], line(&s, 1)), ([60.0, 10.0, 0.0], [[0.0, 20.0, 0.0], [60.0, 10.0, 0.0]]));
// Under ortho the moved end stays level or plumb with the other one.
let s = run("(setvar \"ORTHOMODE\" 1) (command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 0.0, 0.0], [60.0, 20.0, 0.0]));
// Arcs and polylines are left alone: with a line the point still moves it, without one there
// is nothing to change. Enter leaves lines alone too.
let s = run("(command \"_.CHANGE\" a r p \"\" '(60.0 10.0))");
assert_eq!(line(&s, 0)[1], [60.0, 10.0, 0.0]);
assert_eq!(said(&s, "No changeable object selected"), 0, "{:?}", s.log);
for code in ["(command \"_.CHANGE\" r \"\" '(160.0 10.0))", "(command \"_.CHANGE\" p \"\" '(60.0 130.0))", "(command \"_.CHANGE\" a \"\" \"\")"] {
let s = run(code);
assert_eq!(said(&s, "No changeable object selected"), 1, "{code}: {:?}", s.log);
assert!(s.running.is_none(), "{code}");
assert_eq!(line(&s, 0)[1], [50.0, 0.0, 0.0], "{code}");
}
}
#[test]
fn a_circle_passes_through_the_point_or_takes_a_radius() {
let s = run("(command \"_.CHANGE\" c \"\" '(115.0 0.0))");
assert!(near(radius(&s, 2), 15.0));
let s = run("(command \"_.CHANGE\" c \"\" \"\" 20.0)");
assert!(near(radius(&s, 2), 20.0));
// With other objects the point is ignored, said as each circle comes up.
let s = run("(command \"_.CHANGE\" c e \"\" '(110.0 10.0) 8.0)");
assert_eq!((radius(&s, 2), radius(&s, 4)), (8.0, 3.0));
assert_eq!(said(&s, "Change point ignored"), 2, "{:?}", s.log);
assert!(s.prompt_text().contains("Specify new circle radius <no change>"), "{}", s.prompt_text());
let s = run("(command \"_.CHANGE\" a c \"\" '(60.0 10.0) 12.0)");
assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 12.0));
assert_eq!(said(&s, "Change point ignored"), 1, "{:?}", s.log);
}
#[test]
fn a_text_is_placed_by_the_point_then_asked_the_rest() {
let s = run("(command \"_.CHANGE\" t \"\" '(10.0 60.0) \"\" 4.0 \"\" \"\")");
let t = text(&s, 3);
assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (10.0, 60.0, 4.0, "ABC"));
// Enter asks for the insertion point first.
let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"\" 5.0 30 \"XYZ\")");
let t = text(&s, 3);
assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (0.0, 50.0, 5.0, "XYZ"));
assert!(near(t.rotation.to_degrees(), 30.0));
// A style that does not exist is refused, and asked for again.
let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"5\")");
assert_eq!(said(&s, "Style \"5\" not defined - use STYLE command."), 1, "{:?}", s.log);
assert!(s.prompt_text().contains("Enter new text style <Standard>"), "{}", s.prompt_text());
// Justified text is placed by its alignment point, with the point or after Enter.
for (justify, code) in [("(72 . 1) (73 . 2)", "'(10.0 60.0) \"\" \"\" \"\" \"\""), ("(72 . 2)", "\"\" '(10.0 60.0) \"\" \"\" \"\" \"\"")] {
let s = run(&format!(
"(entmake '((0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\") {justify} (11 0.0 50.0 0.0))) (command \"_.CHANGE\" (entlast) \"\" {code})"
));
let at = text(&s, 7).align_pt.unwrap();
assert_eq!((at.x, at.y), (10.0, 60.0), "{justify}");
}
}
#[test]
fn a_style_of_fixed_height_is_not_asked_for_a_height() {
let mut s = run("");
s.execute("style", &json!({ "name": "FIXH", "height": 5.0 })).unwrap();
s.cmdline("(entmake '((0 . \"TEXT\") (10 0.0 80.0 0.0) (40 . 5.0) (1 . \"FIX\") (7 . \"FIXH\")))").unwrap();
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 30 \"Q\")").unwrap();
let t = text(&s, 7);
assert_eq!((t.height, t.value.as_str()), (5.0, "Q"));
assert!(near(t.rotation.to_degrees(), 30.0));
assert!(s.running.is_none());
}
#[test]
fn objects_are_asked_about_in_the_order_selected() {
let s = run(
"(setq d (progn (entmake '((0 . \"TEXT\") (10 0.0 70.0 0.0) (40 . 2.5) (1 . \"DEF\"))) (entlast))) (command \"_.CHANGE\" t d \"\" \"\" \"\" \"\" \"\" \"\" \"X1\" \"\" \"\" \"\" \"\" \"X2\")",
);
assert_eq!((text(&s, 3).value.as_str(), text(&s, 7).value.as_str()), ("X1", "X2"));
let s = run("(command \"_.CHANGE\" t c \"\" \"\" \"\" \"\" \"\" \"\" \"TT\" 7.0)");
assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0));
let s = run("(command \"_.CHANGE\" c t \"\" \"\" 7.0 \"\" \"\" \"\" \"\" \"TT\")");
assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0));
}
/// Block K: a circle and attribute TAG1 (default V) 5 above its base point, inserted at (200,0)
/// with the default value.
const BLOCK: &str = "(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0))) (entmake '((0 . \"ATTDEF\") (10 0.0 5.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (entmake '((0 . \"ENDBLK\"))) (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")";
#[test]
fn a_block_reference_is_placed_and_turned_with_its_attributes() {
let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" '(250.0 50.0) 90)"));
let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") };
assert_eq!((i.insert.x, i.insert.y), (250.0, 50.0));
assert!(near(i.rotation.to_degrees(), 90.0));
let a = &i.attribs.first().expect("the attribute").text;
assert!(near(a.insert.x, 245.0) && near(a.insert.y, 50.0), "{:?}", a.insert);
assert!(near(a.rotation.to_degrees(), 90.0));
let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" \"\" '(220.0 30.0) 90)"));
let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") };
assert_eq!((i.insert.x, i.insert.y), (220.0, 30.0));
assert!(near(i.rotation.to_degrees(), 90.0));
}
#[test]
fn an_attribute_definition_is_asked_its_tag_prompt_and_default() {
let s = run(
"(entmake '((0 . \"ATTDEF\") (10 300.0 0.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (command \"_.CHANGE\" (entlast) \"\" '(310.0 10.0) \"\" \"\" \"\" \"T2\" \"P2\" \"V2\")",
);
let EntityKind::AttDef(a) = kind(&s, 7) else { panic!("not an attribute definition") };
assert_eq!((a.text.insert.x, a.text.insert.y), (310.0, 10.0));
assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), ("T2", "P2", "V2"));
}
#[test]
fn objects_on_locked_layers_are_left_out() {
let s = run("(command \"_.-LAYER\" \"_LO\" \"L1\" \"\") (command \"_.CHANGE\" a b \"\" '(60.0 10.0))");
assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 10.0, 0.0], [50.0, 30.0, 0.0]));
assert_eq!(said(&s, "1 was on a locked layer."), 1, "{:?}", s.log);
}
#[test]
fn properties_add_elev_which_is_set_as_the_command_ends() {
let s = run("(command \"_.CHANGE\" a \"\" \"_P\" \"_C\" 2 \"_E\" 10.0 \"\")");
let d = s.doc().unwrap();
assert_eq!(d.model.iter().next().unwrap().common.color, Color::Index(2));
assert_eq!(line(&s, 0), [[0.0, 0.0, 10.0], [50.0, 0.0, 10.0]]);
let s = run("(command \"_.CHANGE\" a c \"\" \"_P\" \"_LA\" \"L1\" \"_T\" 3.0 \"\")");
for e in s.doc().unwrap().model.iter().take(3).step_by(2) {
assert_eq!((e.common.layer.as_str(), e.common.thickness), ("L1", 3.0));
}
let s = run("(command \"_.CHANGE\" c t r p \"\" \"_P\" \"_E\" 5.0 \"\")");
let EntityKind::Circle(c) = kind(&s, 2) else { panic!() };
let EntityKind::Arc(r) = kind(&s, 5) else { panic!() };
let EntityKind::LwPolyline(p) = kind(&s, 6) else { panic!() };
assert_eq!((c.center.z, text(&s, 3).insert.z, r.center.z, p.elevation), (5.0, 5.0, 5.0, 5.0));
// A line between two heights keeps them, said as the command ends; the elevation asked shows
// it varies.
let mut s = run(
"(setq l (progn (entmake '((0 . \"LINE\") (10 0.0 200.0 0.0) (11 50.0 200.0 10.0))) (entlast))) (command \"_.CHANGE\" l \"\" \"_P\" \"_E\")",
);
assert!(s.prompt_text().contains("Specify new elevation <varies>"), "{}", s.prompt_text());
s.cmdline("(command 5.0 \"\")").unwrap();
assert_eq!(line(&s, 7), [[0.0, 200.0, 0.0], [50.0, 200.0, 10.0]]);
assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log);
}
#[test]
fn change_by_json() {
let mut s = run("");
let h: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
let r = s.execute("change", &json!({ "handles": [h[0], h[2]], "point": [60, 10] })).unwrap();
assert_eq!((r["changed"].as_u64(), r["ignored"].as_u64()), (Some(1), Some(1)), "{r}");
assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 10.0));
s.execute("change", &json!({ "handles": [h[2]], "point": [115, 0] })).unwrap();
assert!(near(radius(&s, 2), 15.0));
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
let r = s.execute("change", &json!({ "handles": [h[0], h[1]], "elevation": 4.0 })).unwrap();
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}");
assert_eq!((line(&s, 0)[0][2], line(&s, 1)[0][2]), (4.0, 0.0));
// Neither a point nor an elevation, or one that can't be read, is refused.
for bad in [json!({ "handles": [h[0]] }), json!({ "handles": [h[0]], "point": "nowhere" }), json!({ "handles": [h[0]], "elevation": "high" })] {
assert!(s.execute("change", &bad).is_err(), "{bad}");
}
assert_eq!(line(&s, 0), [[0.0, 0.0, 4.0], [60.0, 10.0, 4.0]]);
}
/// Elev on the other kinds AutoCAD 2024 sets it for, each alone: the z of a point, a block
/// reference with its attributes, a multiline text, an ellipse, a solid and a flat 3D polyline;
/// a 3D polyline between heights keeps them, and a hatch loses its associativity.
#[test]
fn elev_sets_the_z_of_every_kind_with_one() {
let elev = |made: &str| {
let mut s = run(made);
s.log.clear();
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap();
let k = s.doc().unwrap().model.iter().last().unwrap().kind.clone();
(k, s)
};
let (k, _) = elev("(entmake '((0 . \"POINT\") (10 5.0 5.0 0.0)))");
assert!(matches!(k, EntityKind::Point(p) if p.p.z == 4.0));
let (k, _) = elev("(entmake '((0 . \"MTEXT\") (10 0.0 150.0 0.0) (40 . 2.5) (1 . \"AB\")))");
assert!(matches!(k, EntityKind::MText(t) if t.insert.z == 4.0));
let (k, _) = elev("(entmake '((0 . \"ELLIPSE\") (10 50.0 150.0 0.0) (11 20.0 0.0 0.0) (40 . 0.5) (41 . 0.0) (42 . 6.283185307179586)))");
assert!(matches!(k, EntityKind::Ellipse(e) if e.center.z == 4.0));
let (k, _) = elev("(entmake '((0 . \"SOLID\") (10 0.0 170.0 0.0) (11 10.0 170.0 0.0) (12 0.0 180.0 0.0) (13 10.0 180.0 0.0)))");
assert!(matches!(k, EntityKind::Solid(so) if so.corners.iter().all(|c| c.z == 4.0)));
// 3D polylines (made with their z, which a point given to a command doesn't carry).
let poly3d = |pts: serde_json::Value| {
let mut s = run("");
s.execute("3dpoly", &json!({ "points": pts })).unwrap();
s.log.clear();
s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap();
let EntityKind::Polyline3d(p) = s.doc().unwrap().model.iter().last().unwrap().kind.clone() else { panic!("not a 3D polyline") };
(p.points.iter().map(|q| q.z).collect::<Vec<f64>>(), s)
};
let (zs, _) = poly3d(json!([[0, 220, 2], [10, 220, 2], [20, 230, 2]]));
assert_eq!(zs, [4.0, 4.0, 4.0]);
let (zs, s) = poly3d(json!([[0, 200, 0], [10, 200, 5], [20, 210, 0]]));
assert_eq!(zs, [0.0, 5.0, 0.0]);
assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log);
let (k, _) = elev(&format!("{BLOCK} (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")"));
let EntityKind::Insert(i) = k else { panic!("not a block reference") };
assert_eq!((i.insert.z, i.attribs[0].text.insert.z), (4.0, 4.0));
// A hatch: its elevation, and no longer associative.
let mut s = run("(entmake '((0 . \"LWPOLYLINE\") (90 . 4) (70 . 1) (10 100.0 150.0) (10 120.0 150.0) (10 120.0 170.0) (10 100.0 170.0)))");
let poly = s.doc().unwrap().model.iter().last().unwrap().handle.hex();
s.execute("hatch", &json!({ "handles": [poly], "associative": true })).unwrap();
// The hatch is drawn behind its boundary, so it isn't the last object in the list.
let hatch = |s: &Session| {
let d = s.doc().unwrap();
d.model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| (e.handle.hex(), e.kind.clone())).expect("a hatch")
};
let (hex, k) = hatch(&s);
assert!(matches!(&k, EntityKind::Hatch(h) if h.associative));
s.log.clear();
s.cmdline(&format!("(command \"_.CHANGE\" (handent \"{hex}\") \"\" \"_P\" \"_E\" 4.0 \"\")")).unwrap();
let (_, EntityKind::Hatch(h)) = hatch(&s) else { panic!("not a hatch") };
assert_eq!((h.elevation, h.associative), (4.0, false));
assert_eq!(said(&s, "Hatch boundary associativity removed."), 1, "{:?}", s.log);
}
#[test]
fn the_rotation_asked_shows_as_the_drawing_measures_angles() {
// ANGBASE 90°: a text at 0° shows as 270.00.
let s = run("(setvar \"ANGBASE\" (/ pi 2)) (command \"_.CHANGE\" t \"\" \"\" \"\" \"\" \"\")");
assert!(s.prompt_text().contains("Specify new rotation angle <270.00>"), "{}", s.prompt_text());
}
#[test]
fn aligned_and_fit_texts_are_set_by_their_two_points() {
// Aligned: neither height nor rotation is asked; Fit: no rotation.
let s = run(
"(entmake '((0 . \"TEXT\") (10 0.0 300.0 0.0) (11 40.0 300.0 0.0) (40 . 2.5) (1 . \"AL\") (72 . 3))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" \"NEW\")",
);
let t = text(&s, 7);
assert_eq!((t.height, t.rotation, t.value.as_str()), (2.5, 0.0, "NEW"));
assert!(s.running.is_none(), "{}", s.prompt_text());
let s = run(
"(entmake '((0 . \"TEXT\") (10 0.0 320.0 0.0) (11 40.0 320.0 0.0) (40 . 2.5) (1 . \"FI\") (72 . 5))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 5.0 \"FN\")",
);
let t = text(&s, 7);
assert_eq!((t.height, t.rotation, t.value.as_str()), (5.0, 0.0, "FN"));
assert!(s.running.is_none(), "{}", s.prompt_text());
}
#[test]
fn the_json_point_follows_ortho() {
let mut s = run("(setvar \"ORTHOMODE\" 1)");
let a = s.doc().unwrap().model.iter().next().unwrap().handle.hex();
s.execute("change", &json!({ "handles": [a], "point": [60, 10] })).unwrap();
assert_eq!(line(&s, 0)[1], [60.0, 0.0, 0.0]);
}
+194
View File
@@ -0,0 +1,194 @@
//! -ARRAY, the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command`
//! (accoreconsole, every copy read back with `entget`).
use std::f64::consts::TAU;
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
/// A session with the object `made` (an entity list) as `a` in LISP, after `code`.
fn run(made: &str, code: &str) -> Session {
// `entmake` goes through the DXF codec, as in the app.
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let mut s = Session::new();
s.cmdline(&format!("(progn (setq a (progn (entmake '({made})) (entlast))) {code})")).unwrap();
s
}
/// Where each object is: a line's start, a circle's or arc's centre, a polyline's first vertex, a
/// text's insertion point.
fn points(s: &Session) -> Vec<[f64; 2]> {
s.doc()
.unwrap()
.model
.iter()
.map(|e| match &e.kind {
EntityKind::Line(l) => [l.a.x, l.a.y],
EntityKind::Circle(c) => [c.center.x, c.center.y],
EntityKind::Arc(a) => [a.center.x, a.center.y],
EntityKind::LwPolyline(p) => [p.vertices[0].p.x, p.vertices[0].p.y],
EntityKind::Text(t) => [t.insert.x, t.insert.y],
k => panic!("unexpected {k:?}"),
})
.collect()
}
/// `got` holds each of `want` to `tol`, and nothing else.
fn same(got: &[[f64; 2]], want: &[[f64; 2]], tol: f64) -> bool {
got.len() == want.len() && want.iter().all(|w| got.iter().any(|g| (g[0] - w[0]).abs() <= tol && (g[1] - w[1]).abs() <= tol))
}
fn line(a: [f64; 2], b: [f64; 2]) -> String {
format!("(0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0)", a[0], a[1], b[0], b[1])
}
fn circle(c: [f64; 2], r: f64) -> String {
format!("(0 . \"CIRCLE\") (10 {} {} 0.0) (40 . {r})", c[0], c[1])
}
fn arc(c: [f64; 2], r: f64, a1: f64, a2: f64) -> String {
format!("(0 . \"ARC\") (10 {} {} 0.0) (40 . {r}) (50 . {}) (51 . {})", c[0], c[1], a1.to_radians(), a2.to_radians())
}
/// The measured polyline: four vertices with bulges 0.3, 0, −0.7, 0, from `at`, `scale` × the
/// drawn size, `width` wide.
fn polyline(at: [f64; 2], scale: f64, width: f64) -> String {
let v = [(0.0, 0.0, 0.3), (20.0, 5.0, 0.0), (35.0, -10.0, -0.7), (50.0, 0.0, 0.0)];
let vs: String = v.iter().map(|(x, y, b)| format!(" (10 {} {}) (42 . {b})", at[0] + x * scale, at[1] + y * scale)).collect();
format!("(0 . \"LWPOLYLINE\") (90 . 4) (70 . 0) (43 . {width}){vs}")
}
#[test]
fn rectangular_copies_move_by_rows_and_columns() {
let s = run(
&line([-1.1929712440222175, 9.67888030686079], [44.51297824124873, 11.673136668914886]),
"(command \"_.-ARRAY\" a \"\" \"_R\" 2 2 -12.570757111654931 -18.0084920904546)",
);
let want = [
[-19.20146333447681, -2.891876804794138],
[-19.20146333447681, 9.67888030686079],
[-1.192971244022217, -2.891876804794138],
[-1.192971244022217, 9.67888030686079],
];
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
// One row or one column: that distance isn't asked for.
let s = run(&circle([5.801226536663419, -5.877116775641122], 23.343762414841144), "(command \"_.-ARRAY\" a \"\" \"_R\" 1 3 -26.90504352387681)");
assert!(s.running.is_none(), "{}", s.prompt_text());
let want = [[-48.0088605110902, -5.877116775641122], [-21.10381698721339, -5.877116775641122], [5.801226536663419, -5.877116775641122]];
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
let s = run(&polyline([0.5, 0.25], 0.001, 0.0005), "(command \"_.-ARRAY\" a \"\" \"_R\" 3 1 -0.010095474538067209)");
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(same(&points(&s), &[[0.5, 0.2298090509238659], [0.5, 0.239904525461933], [0.5, 0.25]], 1e-12), "{:?}", points(&s));
let s = run(
"(0 . \"TEXT\") (10 5.4185272544331635 4.443702697220587 0.0) (40 . 2.5) (1 . \"AB12\") (50 . 0.20972899)",
"(command \"_.-ARRAY\" a \"\" \"_R\" 1 4 26.03962020976745)",
);
let want = [
[83.53738788373552, 4.443702697220587],
[57.49776767396807, 4.443702697220587],
[31.45814746420061, 4.443702697220587],
[5.418527254433163, 4.443702697220587],
];
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
}
#[test]
fn polar_copies_turn_about_the_centre() {
// Five items around a full circle: 72° apart.
let s = run(
&arc([8.737608057965836, 8.018370437941456], 25.76749991913238, 246.54512163341687, 4.3030133419271355),
"(command \"_.-ARRAY\" a \"\" \"_P\" '(10.631794214160564 -5.07952370466397) 5 360 \"_Y\")",
);
let want = [
[22.50329607545565, 0.769426262850251],
[19.86297201814329, -14.56256796884801],
[4.465473991920418, -16.78931734426366],
[-2.410379072682379, -2.833529910999876],
[8.737608057965836, 8.018370437941456],
];
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
let starts: Vec<f64> =
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Arc(a) = &e.kind { Some(a.start) } else { None }).collect();
for want in [3.046387065797107, 1.789750004361188, 0.5331129429252733, 5.559661188668941, 4.303024127233024] {
assert!(starts.iter().any(|a| ((a - want).rem_euclid(TAU) + 1e-9).rem_euclid(TAU) < 2e-9), "{want}: {starts:?}");
}
// Six items in 270°: 54° apart.
let s = run(
&line([14.349623930607876, -18.237839127538827], [57.5895083326729, 8.515990272720988]),
"(command \"_.-ARRAY\" a \"\" \"_P\" '(9.968618268950948 -10.513614962917108) 6 270 \"_Y\")",
);
let want = [
[2.244394104329227, -14.89462062457403],
[1.884125186851836, -6.839676863508278],
[8.18895082173591, -1.813636035924282],
[15.96098679213407, -3.960114445242588],
[18.79273740462331, -11.50949197954958],
[14.34962393060787, -18.23783912753882],
];
assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s));
}
#[test]
fn polar_copies_not_rotated_move_with_the_group_10_point() {
// A polyline moves with its first vertex: three items in 270°.
let s = run(
&polyline([612345.678, 2398765.432], 1.0, 0.5),
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612362.9804954464 2398768.822523769) 3 270 \"_N\")",
);
let want = [[612359.5899716774, 2398786.125019215], [612377.612669657, 2398758.985274256], [612345.678, 2398765.432]];
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
// A circle with its centre, five items in −90° (clockwise).
let s = run(
&circle([612340.715727557, 2398755.830080357], 26.60109393014347),
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612346.260090821 2398771.804712544) 5 -90 \"_N\")",
);
let want = [
[612330.2854586337, 2398777.349075808],
[612329.3797191395, 2398770.813809208],
[612331.0438632132, 2398764.429398659],
[612335.0245400043, 2398759.16781279],
[612340.715727557, 2398755.830080357],
];
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
// An arc with its centre, its angles unchanged.
let s = run(
&arc([612339.185864084, 2398766.23253733], 13.970352776505141, 238.67958380809327, 80.40871664680844),
"(command \"_.-ARRAY\" a \"\" \"_P\" '(612358.7800675274 2398749.633182222) 7 360 \"_N\")",
);
let want = [
[612359.541179917, 2398775.302075975],
[612379.3233625971, 2398765.042414074],
[612383.6360250826, 2398743.179286296],
[612369.2316445602, 2398726.176073787],
[612346.957013359, 2398726.836542366],
[612333.585383093, 2398744.663345723],
[612339.185864084, 2398766.23253733],
];
assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s));
assert!(s.doc().unwrap().model.iter().all(|e| matches!(&e.kind, EntityKind::Arc(a) if (a.start - 4.165744594740973).abs() < 1e-9)));
}
#[test]
fn the_type_asked_defaults_to_the_one_used_last() {
let mut s = run(&circle([0.0, 0.0], 1.0), "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.0 0.0) 3 360 \"_Y\")");
s.cmdline("(command \"_.-ARRAY\" a \"\")").unwrap();
assert!(s.prompt_text().contains("[Rectangular/Polar] <P>"), "{}", s.prompt_text());
}
#[test]
fn classic_array_by_json() {
let mut s = run(&circle([0.0, 0.0], 1.0), "");
let first = s.doc().unwrap().model.iter().next().unwrap().handle.hex();
let r = s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [10, 0], "count": 4, "rotate": false })).unwrap();
assert_eq!(r["created"].as_u64(), Some(3), "{r}");
assert!(same(&points(&s), &[[0.0, 0.0], [10.0, -10.0], [20.0, 0.0], [10.0, 10.0]], 1e-9), "{:?}", points(&s));
let r = s.execute("-array", &json!({ "handles": [first], "rows": 2, "cols": 3, "rowSpacing": 5, "colSpacing": 7 })).unwrap();
assert_eq!(r["created"].as_u64(), Some(5), "{r}");
// An unknown type, or an array past MAXARRAY, is refused before anything is made.
assert!(s.execute("-array", &json!({ "handles": [first], "type": "path" })).is_err());
assert!(s.execute("-array", &json!({ "handles": [first], "rows": 4294967296u64, "cols": 4294967296u64 })).is_err());
assert!(s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [0, 0], "count": u64::MAX })).is_err());
assert_eq!(s.doc().unwrap().model.iter().count(), 4 + 5);
}
+223
View File
@@ -0,0 +1,223 @@
//! -UNITS asks for the drawing's units at the command line, as AutoCAD 2024 asks (measured with
//! accoreconsole, running these scripts from these settings): length format and precision, angle
//! format and precision, the direction of angle 0, and whether angles turn clockwise.
use std::f64::consts::FRAC_PI_2;
use cadcraft_engine::Session;
/// LUNITS, LUPREC, AUNITS, AUPREC, ANGBASE (radians; AutoCAD's 1.570796326794896 is pi/2), ANGDIR.
type Units = (i64, i64, i64, i64, f64, i64);
const DEFAULT: Units = (2, 4, 0, 0, 0.0, 0);
fn session(u: Units) -> Session {
let mut s = Session::new();
let h = &mut s.doc_mut().unwrap().header;
h.set_i64("LUNITS", u.0);
h.set_i64("LUPREC", u.1);
h.set_i64("AUNITS", u.2);
h.set_i64("AUPREC", u.3);
h.set_f64("ANGBASE", u.4);
h.set_i64("ANGDIR", u.5);
s
}
fn units(s: &Session) -> Units {
let h = &s.doc().unwrap().header;
(h.i64("LUNITS", 2), h.i64("LUPREC", 4), h.i64("AUNITS", 0), h.i64("AUPREC", 0), h.f64("ANGBASE", 0.0), h.i64("ANGDIR", 0))
}
fn same(a: Units, b: Units) -> bool {
(a.0, a.1, a.2, a.3, a.5) == (b.0, b.1, b.2, b.3, b.5) && (a.4 - b.4).abs() <= 1e-12
}
fn run(s: &mut Session, answers: &str) {
s.cmdline(&format!("(command \"_.-UNITS\" {answers})")).unwrap();
assert!(s.running.is_none(), "{answers}: {}", s.prompt_text());
}
#[test]
fn the_measured_scripts_set_the_units() {
let cases: [(Units, &str, Units); 21] = [
(DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)),
(DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)),
(DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)),
(DEFAULT, "5 8 5 2 \"N\" \"_N\"", (5, 3, 4, 2, FRAC_PI_2, 0)),
((4, 3, 1, 2, 0.5, 1), "\"\" \"\" \"\" \"\" \"\" \"\"", (4, 3, 1, 2, 0.5, 1)),
((2, 4, 0, 0, FRAC_PI_2, 1), "2 4 1 0 45 \"_N\"", (2, 4, 0, 0, 5.497787143782137, 0)),
(DEFAULT, "1 2 4 3 30 \"_N\"", (1, 2, 3, 3, 4.867258771281656, 0)),
((2, 2, 0, 2, 0.0, 0), "3 \"\" 2 \"\" \"\" \"\"", (3, 4, 1, 4, 0.0, 0)),
((2, 2, 0, 2, 0.0, 0), "2 \"\" 1 \"\" \"\" \"\"", (2, 2, 0, 2, 0.0, 0)),
(DEFAULT, "4 3 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)),
((2, 4, 0, 0, 0.0, 1), "2 4 5 4 \"N45dE\" \"\"", (2, 4, 4, 4, 5.497787143782137, 1)),
(DEFAULT, "6 \"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 0, 0, 0.0, 0)),
((2, 2, 0, 0, 0.0, 0), "4 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)),
((5, 6, 0, 0, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (5, 6, 0, 0, 0.0, 0)),
((2, 4, 0, 0, 0.0, 1), "\"\" \"\" 5 \"\" \"N\" \"\"", (2, 4, 4, 4, 4.712388980384689, 1)),
((2, 4, 3, 4, 0.0, 0), "\"\" \"\" \"\" \"\" \"45d\" \"\"", (2, 4, 3, 4, 0.7853981633974482, 0)),
(DEFAULT, "5 1 \"\" \"\" \"\" \"\"", (5, 0, 0, 0, 0.0, 0)),
((1, 7, 0, 7, 0.0, 0), "3 \"\" 1 \"\" \"\" \"\"", (3, 4, 0, 7, 0.0, 0)),
(DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)),
((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)),
((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)),
];
for (from, answers, to) in cases {
let mut s = session(from);
run(&mut s, answers);
assert!(same(units(&s), to), "{answers}: {:?}, AutoCAD {to:?}", units(&s));
}
}
/// The prompt, after the command's name.
fn asks(s: &Session) -> String {
let p = s.prompt_text();
p.strip_prefix("-UNITS ").unwrap_or(&p).to_string()
}
fn ends_with(s: &Session, lines: &[&str]) -> bool {
s.log.len() >= lines.len() && s.log[s.log.len() - lines.len()..].iter().zip(lines).all(|(a, b)| a == b)
}
#[test]
fn prompts_and_listings_show_the_drawings_units() {
// Architectural to 1/8, degrees/minutes/seconds to 2 places, angle 0 at 0.5 radians, clockwise.
let mut s = session((4, 3, 1, 2, 0.5, 1));
s.cmdline("-units").unwrap();
assert!(s.log.iter().any(|l| l == " 4. Architectural 1'-3 1/2\""), "{:?}", s.log);
assert_eq!(asks(&s), "Enter choice, 1 to 5 <4>:");
s.cmdline("").unwrap();
assert!(ends_with(&s, &["Enter denominator of smallest fraction to display"]), "{:?}", s.log);
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <8>:");
s.cmdline("").unwrap();
assert!(ends_with(&s, &[" 4. Radians 0.7854r", " 5. Surveyor's units N 45d0'0\" E"]), "{:?}", s.log);
assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:");
s.cmdline("").unwrap();
assert_eq!(asks(&s), "Enter number of fractional places for display of angles (0 to 8) <2>:");
s.cmdline("").unwrap();
let directions = [
"Direction for angle 0d0':",
" East 3 o'clock = 0d0'",
" North 12 o'clock = 270d0'",
" West 9 o'clock = 180d0'",
" South 6 o'clock = 90d0'",
];
assert!(ends_with(&s, &directions), "{:?}", s.log);
assert_eq!(asks(&s), "Enter direction for angle 0d0' <331d21'>:");
s.cmdline("").unwrap();
assert_eq!(asks(&s), "Measure angles clockwise? [Yes/No] <Y>:");
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(same(units(&s), (4, 3, 1, 2, 0.5, 1)), "{:?}", units(&s));
// Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it.
let mut s = session((2, 4, 0, 0, 0.0, 1));
s.script(
"-units
5
",
)
.unwrap();
let directions = [
"Direction for angle E:",
" East 3 o'clock = E",
" North 12 o'clock = S",
" West 9 o'clock = W",
" South 6 o'clock = N",
];
assert!(ends_with(&s, &directions), "{:?}", s.log);
assert_eq!(asks(&s), "Enter direction for angle E <E>:");
// The listing shows directions to the precision just given.
let mut s = session(DEFAULT);
s.script(
"-units
2
2
",
)
.unwrap();
let directions = [
"Direction for angle 0d0':",
" East 3 o'clock = 0d0'",
" North 12 o'clock = 90d0'",
" West 9 o'clock = 180d0'",
" South 6 o'clock = 270d0'",
];
assert!(ends_with(&s, &directions), "{:?}", s.log);
assert_eq!(asks(&s), "Enter direction for angle 0d0' <0d0'>:");
}
#[test]
fn a_rejected_answer_is_asked_again() {
let mut s = session(DEFAULT);
s.cmdline("-units").unwrap();
s.cmdline("6").unwrap();
assert!(ends_with(&s, &["Requires an integer between 1 and 5."]), "{:?}", s.log);
s.cmdline("2.5").unwrap();
assert!(ends_with(&s, &["Requires an integer value."]), "{:?}", s.log);
assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:");
s.cmdline("4").unwrap();
s.cmdline("3").unwrap();
assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log);
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:");
s.script(
"
9
",
)
.unwrap();
assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log);
s.script(
"
abc
",
)
.unwrap();
assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log);
s.script(
"
",
)
.unwrap();
assert!(s.running.is_none());
assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s));
}
#[test]
fn each_answer_is_kept_as_it_is_given() {
// Esc after the angle format: the answers before it are kept, and the new angle format's
// precision is reset to 4.
let mut s = session(DEFAULT);
s.script(
"-units
4
16
2
",
)
.unwrap();
s.cancel();
assert!(s.running.is_none());
assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s));
let mut s = session(DEFAULT);
s.script(
"-units
3
2
3
5
90
",
)
.unwrap();
s.cancel();
assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s));
}
@@ -0,0 +1,82 @@
//! INSERT's attribute questions as AutoCAD 2024 asks them from AutoLISP's `command` (accoreconsole,
//! the attributes read back with `entget`).
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
/// A session with block K: a circle and attributes (tag, DXF flags, default value), each with the
/// prompt "P" + tag.
fn with_block(defs: &[(&str, i32, &str)]) -> Session {
// `entmake` goes through the DXF codec, as in the app.
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let mut s = Session::new();
let mut code =
String::from("(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0)))");
for (i, (tag, flags, default)) in defs.iter().enumerate() {
let y = 5.0 + 3.0 * i as f64;
code += &format!(
" (entmake '((0 . \"ATTDEF\") (10 0.0 {y:.1} 0.0) (40 . 2.5) (1 . \"{default}\") (3 . \"P{tag}\") (2 . \"{tag}\") (70 . {flags})))"
);
}
code += " (entmake '((0 . \"ENDBLK\")))";
s.cmdline(&format!("(progn {code})")).unwrap();
s.log.clear();
s
}
/// The attribute values of the last block reference, by tag.
fn values(s: &Session) -> Vec<(String, String)> {
let d = s.doc().unwrap();
let Some(EntityKind::Insert(i)) = d.model.iter().last().map(|e| &e.kind) else { panic!("no block reference") };
i.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect()
}
fn said(s: &Session, line: &str) -> usize {
s.log.iter().filter(|l| l.as_str() == line).count()
}
fn pair(tag: &str, value: &str) -> (String, String) {
(tag.to_string(), value.to_string())
}
#[test]
fn attreq_0_inserts_the_default_values_without_asking() {
let mut s = with_block(&[("T1", 0, "V1"), ("T2", 0, "V2")]);
s.cmdline("(progn (setvar \"ATTREQ\" 0) (command \"_.-INSERT\" \"K\" '(100.0 0.0) \"\" \"\" 0))").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "V2")]);
assert_eq!(said(&s, "Enter attribute values"), 0, "{:?}", s.log);
s.cmdline("(setvar \"ATTREQ\" 1)").unwrap();
s.cmdline("(princ (getvar \"ATTREQ\"))").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("1"), "{:?}", s.log);
}
#[test]
fn a_preset_attribute_is_not_asked() {
let mut s = with_block(&[("T1", 8, "V1"), ("T2", 0, "V2")]);
s.cmdline("(command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"X\")").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "X")]);
assert_eq!(said(&s, "Enter attribute values"), 1, "{:?}", s.log);
}
#[test]
fn a_verify_attribute_is_asked_again_with_the_value_typed() {
let mut s = with_block(&[("T1", 4, "V1")]);
s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\")").unwrap();
assert_eq!(said(&s, "Verify attribute values"), 1, "{:?}", s.log);
assert!(s.prompt_text().contains("PT1 <X>"), "{}", s.prompt_text());
// A click is no answer.
s.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap();
assert!(s.prompt_text().contains("PT1 <X>"), "{}", s.prompt_text());
// Typed at the prompt, the whole line is the value.
s.cmdline("Y").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(values(&s), [pair("T1", "Y")]);
// Enter keeps the value typed.
let mut s = with_block(&[("T1", 4, "V1")]);
s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\" \"\")").unwrap();
assert_eq!(values(&s), [pair("T1", "X")]);
}
+7
View File
@@ -19,13 +19,16 @@ mod break_closed_polyline;
mod break_join_locked_layers;
mod break_keeps_z;
mod burst;
mod change_command;
mod chprop_command;
mod classic_array;
mod compare_export;
mod constraint_geometry;
mod constraint_infer;
mod copy_count;
mod count;
mod dash_block;
mod dash_units;
mod data_extraction;
mod dim_baseline_spacing;
mod dim_chain;
@@ -57,6 +60,7 @@ mod hatch_options;
mod helix;
mod inquiry_keywords;
mod inquiry_z;
mod insert_attribute_prompts;
mod insert_prompts;
mod isometric;
mod join_close;
@@ -71,6 +75,7 @@ mod malformed_handles;
mod matchprop;
mod measure_distance_snapping;
mod metric_linetypes;
mod mirror_degenerate_line;
mod mleader_content;
mod mleader_remember;
mod mline;
@@ -107,10 +112,12 @@ mod query_entities;
mod quickcalc;
mod recent_commands;
mod rectang_ortho;
mod rectang_polygon_order;
mod refedit;
mod region;
mod rename_cmdline;
mod rotate_reference;
mod scale_offset_z;
mod select_keywords;
mod select_similar;
mod select_underscore_keywords;
@@ -0,0 +1,17 @@
//! MIRROR refuses a mirror line whose points are closer than the geometry tolerance.
use cadcraft_engine::Session;
use serde_json::json;
#[test]
fn nearly_coincident_mirror_points_are_refused() {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap();
let before = s.doc().unwrap().model.iter().count();
for erase in [false, true] {
let r = s.execute("mirror", &json!({ "handles": ["100"], "p1": [0, 0], "p2": [5e-10, 0], "erase": erase }));
let err = r.expect_err("mirror line is degenerate").to_string();
assert!(err.contains("mirror line points coincide"), "{err}");
}
assert_eq!(s.doc().unwrap().model.iter().count(), before);
}
@@ -0,0 +1,215 @@
//! RECTANG and POLYGON vertices in AutoCAD 2024's order, and the RECTANG modes it remembers from
//! one rectangle to the next. The scripts and numbers were measured with accoreconsole; the
//! RECTANG scripts reset Chamfer, Fillet, Width, Elevation and Thickness first, but not Rotation.
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
type Vertex = (f64, f64, f64);
const RESET: &str = "\"_C\" 0 0 \"_F\" 0 \"_W\" 0 \"_E\" 0 \"_T\" 0";
/// The last polyline's vertices (x, y, bulge).
fn last_polyline(s: &Session) -> Vec<Vertex> {
let d = s.doc().unwrap();
match &d.model.iter().last().unwrap().kind {
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(),
k => panic!("not a polyline: {k:?}"),
}
}
fn expect(got: &[Vertex], want: &[Vertex]) {
assert_eq!(got.len(), want.len(), "{got:?}");
for (g, w) in got.iter().zip(want) {
let tol = 1e-9 * (1.0 + w.0.abs().max(w.1.abs()));
assert!((g.0 - w.0).abs() < tol && (g.1 - w.1).abs() < tol && (g.2 - w.2).abs() < 1e-9, "{got:?}\n!= {want:?}");
}
}
fn lisp(s: &mut Session, expr: &str) {
s.cmdline(expr).unwrap();
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
}
fn rectang(s: &mut Session, rest: &str) {
lisp(s, &format!("(command \"_.RECTANG\" {RESET} {rest})"));
}
#[test]
fn a_rectangle_starts_at_its_first_corner_and_runs_along_x_first() {
let mut s = Session::new();
rectang(&mut s, "'(-2.2557479240466947 -9.488221049171962) '(-31.36203661716106 -47.886714708394166)");
expect(
&last_polyline(&s),
&[
(-2.255747924046694, -9.488221049171962, 0.0),
(-31.36203661716105, -9.488221049171962, 0.0),
(-31.36203661716105, -47.88671470839417, 0.0),
(-2.255747924046694, -47.88671470839417, 0.0),
],
);
}
#[test]
fn chamfers_cut_the_edge_into_each_corner_by_the_first_distance() {
let mut s = Session::new();
rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(4.8089419289768 29.66648885915597) '(28.114797694689763 43.85884965263156)");
expect(
&last_polyline(&s),
&[
(4.8089419289768, 31.91829932479258, 0.0),
(8.575279497862834, 29.66648885915597, 0.0),
(25.86298722905314, 29.66648885915597, 0.0),
(28.11479769468976, 33.432826428042, 0.0),
(28.11479769468976, 41.60703918699494, 0.0),
(24.34846012580373, 43.85884965263156, 0.0),
(7.060752394613416, 43.85884965263156, 0.0),
(4.8089419289768, 40.09251208374553, 0.0),
],
);
}
#[test]
fn fillet_arcs_start_on_the_edge_into_each_corner() {
let mut s = Session::new();
rectang(&mut s, "\"_F\" 0.6042729201106594 '(-13.457476758444333 -29.884431038180395) '(14.673991980287038 27.14695046607133)");
let b = 0.414213562373095;
expect(
&last_polyline(&s),
&[
(-13.45747675844433, -29.28015811806973, b),
(-12.85320383833367, -29.88443103818039, 0.0),
(14.06971906017638, -29.88443103818039, b),
(14.67399198028703, -29.28015811806973, 0.0),
(14.67399198028703, 26.54267754596067, b),
(14.06971906017638, 27.14695046607133, 0.0),
(-12.85320383833367, 27.14695046607133, b),
(-13.45747675844433, 26.54267754596067, 0.0),
],
);
}
#[test]
fn fillet_arcs_turn_clockwise_when_the_second_corner_is_below_the_first() {
// The second corner down and right: the outline runs clockwise and the bulges are negative
// (AutoCAD gave -0.414213562373095 for such a rectangle, there also rotated).
let mut s = Session::new();
rectang(&mut s, "\"_F\" 1.0 '(0.0 0.0) '(10.0 -5.0)");
let b = -0.414213562373095;
expect(
&last_polyline(&s),
&[(0.0, -1.0, b), (1.0, 0.0, 0.0), (9.0, 0.0, b), (10.0, -1.0, 0.0), (10.0, -4.0, b), (9.0, -5.0, 0.0), (1.0, -5.0, b), (0.0, -4.0, 0.0)],
);
}
#[test]
fn the_next_rectangle_keeps_the_rotation_and_lists_the_modes() {
let mut s = Session::new();
rectang(&mut s, "'(27.647422687394844 28.925709913163516) \"_R\" 50.50299224048081 '(46.86449612780332 4.979921576806525)");
expect(
&last_polyline(&s),
&[
(27.64742268739484, 28.92570991316351, 0.0),
(23.66889931823719, 24.09886156025833, 0.0),
(46.86449612780331, 4.979921576806525, 0.0),
(50.84301949696097, 9.80676992971171, 0.0),
],
);
// No Rotation option this time: the rectangle still turns by 50.50 degrees.
rectang(&mut s, "'(14.3947813348612 23.649728466870016) \"_D\" 19.35761675393126 49.775239210761946 '(-4.9628354190700605 -26.12551074389193)");
assert!(s.log.iter().any(|l| l == "Current rectangle modes: Rotation=50.50"), "{:?}", s.log);
// The next Rotation prompt offers it as its default, as AutoCAD shows it.
s.cmdline("RECTANG 0,0 R").unwrap();
assert!(s.prompt_text().ends_with("Specify rotation angle or [Pick points] <50.50>:"), "{}", s.prompt_text());
s.cancel();
expect(
&last_polyline(&s),
&[
(14.3947813348612, 23.64972846687001, 0.0),
(2.082603004810238, 8.712272486646885, 0.0),
(40.4920546468795, -22.94666721518322, 0.0),
(52.80423297693047, -8.009211234960095, 0.0),
],
);
}
#[test]
fn elevation_thickness_and_a_picked_rotation_carry_over_together() {
let mut s = Session::new();
rectang(
&mut s,
"\"_E\" 7.5 \"_T\" 2.25 '(-18.42481827666694 -20.50343704206707) \"_R\" \"_P\" '(0.0 0.0) '(10.0 6.0) '(10.197290578707722 25.5222006119985)",
);
expect(
&last_polyline(&s),
&[
(-18.42481827666694, -20.50343704206706, 0.0),
(22.92627837613747, 4.307220949615576, 0.0),
(10.19729057870772, 25.5222006119985, 0.0),
(-31.15380607409669, 0.7115426203158534, 0.0),
],
);
let d = s.doc().unwrap();
let e = d.model.iter().last().unwrap();
let EntityKind::LwPolyline(p) = &e.kind else { panic!("not a polyline") };
assert_eq!((p.elevation, e.common.thickness), (7.5, 2.25));
s.cmdline("RECTANG").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Elevation=7.5000 Thickness=2.2500 Rotation=30.96"));
s.cancel();
}
#[test]
fn chamfer_and_fillet_are_remembered_one_at_a_time() {
let mut s = Session::new();
rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(0.0 0.0) '(40.0 30.0)");
rectang(&mut s, "\"_F\" 0.6042729201106594 '(0.0 0.0) '(40.0 30.0)");
let logs: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Current rectangle modes:")).collect();
assert_eq!(logs, ["Current rectangle modes: Chamfer=2.2518 x 3.7663"]);
s.cmdline("RECTANG").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=0.6043"));
s.cancel();
}
#[test]
fn setting_a_fillet_clears_the_chamfer_and_back() {
// A chamfer set after a fillet makes a chamfered rectangle, and the other way round.
let mut s = Session::new();
s.script("RECTANG F 1 C 2 3 0,0 40,30\n").unwrap();
assert!(last_polyline(&s).iter().all(|v| v.2 == 0.0), "{:?}", last_polyline(&s));
assert_eq!(last_polyline(&s).first().map(|v| (v.0, v.1)), Some((0.0, 2.0)));
s.script("RECTANG C 2 3 F 1 0,0 40,30\n").unwrap();
assert!(s.log.iter().any(|l| l == "Current rectangle modes: Chamfer=2.0000 x 3.0000"), "{:?}", s.log);
expect(&last_polyline(&s)[..1], &[(0.0, 1.0, 0.414213562373095)]);
s.cmdline("RECTANG").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=1.0000"));
s.cancel();
}
#[test]
fn the_json_form_ignores_the_remembered_modes() {
let mut s = Session::new();
rectang(&mut s, "'(0.0 0.0) \"_R\" 30 '(10.0 10.0)");
s.execute("rectang", &json!({ "p1": [100, 0], "p2": [110, 10] })).unwrap();
expect(&last_polyline(&s), &[(100.0, 0.0, 0.0), (110.0, 0.0, 0.0), (110.0, 10.0, 0.0), (100.0, 10.0, 0.0)]);
}
#[test]
fn a_circumscribed_polygon_starts_counter_clockwise_from_the_picked_edge() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.POLYGON\" 4 '(-13.660465775726184 6.051830973104977) \"_C\" '(-33.469717497592136 8.913096552751803))");
expect(
&last_polyline(&s),
&[
(-36.33098307723896, -10.89615516911414, 0.0),
(3.287520366492948, -16.61868632840779, 0.0),
(9.01005152578659, 22.9998171153241, 0.0),
(-30.60845191794531, 28.72234827461775, 0.0),
],
);
lisp(&mut s, "(command \"_.POLYGON\" 3 '(-4.866064513702604 -0.5675999154110016) \"_C\" '(-33.004110114447 14.119144448101377))");
expect(
&last_polyline(&s),
&[(-58.44229754982628, -34.61738015807783, 0.0), (51.41002668778618, -29.94108864243579, 0.0), (-7.565922679067647, 62.85566905428061, 0.0)],
);
}
+184
View File
@@ -0,0 +1,184 @@
//! SCALE scales heights too and OFFSET keeps them, as AutoCAD 2024 does (measured with
//! accoreconsole, running these scripts on these drawings): SCALE scales every Z and elevation
//! about the base point's Z (the current elevation for a point given without one) and every
//! thickness by the factor; OFFSET's copy is at the source's height, and a circle's or arc's copy
//! has no thickness while a polyline's keeps it.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
use serde_json::json;
fn add(s: &mut Session, kind: EntityKind, thickness: f64) -> Handle {
let space = s.space();
s.doc_mut().unwrap().add(&space, Common { thickness, ..Common::default() }, kind).unwrap()
}
fn lisp(s: &mut Session, expr: &str) {
s.cmdline(expr).unwrap();
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
}
fn get(s: &Session, h: Handle) -> (EntityKind, f64) {
let e = s.doc().unwrap().entity(h).unwrap().clone();
(e.kind.clone(), e.common.thickness)
}
fn last(s: &Session) -> (EntityKind, f64) {
let e = s.doc().unwrap().model.iter().last().unwrap().clone();
(e.kind.clone(), e.common.thickness)
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * (1.0 + b.abs())
}
fn near3(a: Vec3, b: Vec3) -> bool {
close(a.x, b.x) && close(a.y, b.y) && close(a.z, b.z)
}
fn scale(s: &mut Session, h: Handle, base: &str) {
lisp(s, &format!("(command \"_.SCALE\" (handent \"{}\") \"\" '({base}) 1.7)", h.hex()));
}
#[test]
fn scale_scales_a_lines_z() {
let mut s = Session::new();
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
scale(&mut s, h, "2.0 3.0");
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
assert!(near3(l.a, Vec3::new(-1.4, -2.1, 11.9)) && near3(l.b, Vec3::new(66.6, -2.1, 11.9)), "{l:?}");
// A sloped line at survey coordinates.
let mut s = Session::new();
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(612345.678, 2398765.432, 3.0), b: Vec3::new(612375.678, 2398775.432, 8.0) }), 0.0);
scale(&mut s, h, "612347.678 2398768.432");
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
assert!(near3(l.a, Vec3::new(612344.278, 2398763.332, 5.1)) && near3(l.b, Vec3::new(612395.278, 2398780.332, 13.6)), "{l:?}");
}
#[test]
fn scale_scales_the_centre_z_and_thickness_of_circles_and_arcs() {
let mut s = Session::new();
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.5, 0.25, 0.005), radius: 0.01 }), 0.002);
scale(&mut s, c, "0.502 0.253");
let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() };
assert!(near3(k.center, Vec3::new(0.4986, 0.2479, 0.0085)) && close(k.radius, 0.017) && close(t, 0.0034), "{k:?} {t}");
let mut s = Session::new();
let arc = Arc { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0, start: 30f64.to_radians(), end: 200f64.to_radians() };
let a = add(&mut s, EntityKind::Arc(arc), 1.5);
scale(&mut s, a, "2.0 3.0");
let (EntityKind::Arc(k), t) = get(&s, a) else { panic!() };
assert!(near3(k.center, Vec3::new(-1.4, -2.1, 7.65)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}");
}
#[test]
fn scale_scales_a_polylines_elevation_and_thickness_and_a_points_z() {
let mut s = Session::new();
let vs = [(612345.678, 2398765.432, 0.0), (612365.678, 2398765.432, 0.4), (612375.678, 2398780.432, 0.0), (612350.678, 2398785.432, 0.0)];
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
scale(&mut s, p, "612347.678 2398768.432");
let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() };
assert!(close(k.elevation, 21.25) && close(t, 1.7), "{} {t}", k.elevation);
let first = k.vertices.first().unwrap().p;
assert!(close(first.x, 612344.278) && close(first.y, 2398763.332), "{first:?}");
let pt = add(&mut s, EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 6.0), angle: 0.0 }), 0.0);
scale(&mut s, pt, "2.0 3.0");
let EntityKind::Point(k) = get(&s, pt).0 else { panic!() };
assert!(near3(k.p, Vec3::new(7.1, 6.4, 10.2)), "{k:?}");
}
#[test]
fn scale_scales_heights_about_the_current_elevation() {
// ELEVATION 5: the base point, given without a Z, is at Z = 5.
let mut s = Session::new();
s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0);
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
scale(&mut s, h, "2.0 3.0");
let EntityKind::Line(l) = get(&s, h).0 else { panic!() };
assert!(near3(l.a, Vec3::new(-1.4, -2.1, 8.4)) && near3(l.b, Vec3::new(66.6, -2.1, 8.4)), "{l:?}");
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0 }), 1.5);
scale(&mut s, c, "2.0 3.0");
let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() };
assert!(near3(k.center, Vec3::new(-1.4, -2.1, 4.15)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}");
let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0)];
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
scale(&mut s, p, "2.0 3.0");
let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() };
assert!(close(k.elevation, 17.75) && close(t, 1.7), "{} {t}", k.elevation);
let last = k.vertices.last().unwrap().p;
assert!(close(last.x, 49.6) && close(last.y, 23.4), "{last:?}");
}
#[test]
fn json_scale_takes_the_base_points_z() {
let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) });
let z = |s: &Session, h: Handle| {
let EntityKind::Line(l) = get(s, h).0 else { panic!() };
(l.a.z, l.b.z)
};
let mut s = Session::new();
let h = add(&mut s, line(), 0.0);
s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, 1.0], "factor": 1.7 })).unwrap();
let (a, b) = z(&s, h);
assert!(close(a, 11.2) && close(b, 11.2), "{a} {b}");
// Without one, the current elevation (not measured through JSON: the same base point as the
// command line's).
let mut s = Session::new();
s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0);
let h = add(&mut s, line(), 0.0);
s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0], "factor": 1.7 })).unwrap();
let (a, b) = z(&s, h);
assert!(close(a, 8.4) && close(b, 8.4), "{a} {b}");
// A Z that isn't a number is refused, the drawing left as it was.
assert!(s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, "1"], "factor": 1.7 })).is_err());
assert_eq!(z(&s, h), (a, b));
}
#[test]
fn move_and_rotate_leave_heights_alone() {
let mut s = Session::new();
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 2.0);
lisp(&mut s, &format!("(command \"_.ROTATE\" (handent \"{}\") \"\" '(0.0 0.0) 90)", h.hex()));
lisp(&mut s, &format!("(command \"_.MOVE\" (handent \"{}\") \"\" '(0.0 0.0) '(5.0 5.0))", h.hex()));
let (EntityKind::Line(l), t) = get(&s, h) else { panic!() };
assert!(close(l.a.z, 7.0) && close(l.b.z, 7.0) && close(t, 2.0), "{l:?} {t}");
}
#[test]
fn offset_keeps_a_lines_z() {
let mut s = Session::new();
let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0);
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(20.0 0.0)) '(10.0 5.0) \"\")", h.hex()));
let EntityKind::Line(l) = last(&s).0 else { panic!() };
assert!(near3(l.a, Vec3::new(0.0, 3.0, 7.0)) && near3(l.b, Vec3::new(40.0, 3.0, 7.0)), "{l:?}");
}
#[test]
fn offset_keeps_the_centre_z_of_circles_and_arcs_but_not_their_thickness() {
let mut s = Session::new();
let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(612345.678, 2398765.432, 5.0), radius: 10.0 }), 2.0);
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(612355.678 2398765.432)) '(612345.678 2398780.432) \"\")", c.hex()));
let (EntityKind::Circle(k), t) = last(&s) else { panic!() };
assert!(close(k.center.z, 5.0) && close(k.radius, 13.0) && t == 0.0, "{k:?} {t}");
let mut s = Session::new();
let arc = Arc { center: Vec3::new(-35000.0, 81000.0, 225.0), radius: 500.0, start: 30f64.to_radians(), end: 200f64.to_radians() };
let a = add(&mut s, EntityKind::Arc(arc), 75.0);
lisp(&mut s, &format!("(command \"_.OFFSET\" 150.0 (list (handent \"{}\") '(-35000.0 81500.0)) '(-35000.0 81750.0) \"\")", a.hex()));
let (EntityKind::Arc(k), t) = last(&s) else { panic!() };
assert!(close(k.center.z, 225.0) && close(k.radius, 650.0) && t == 0.0, "{k:?} {t}");
}
#[test]
fn offset_keeps_a_polylines_elevation_and_thickness() {
let mut s = Session::new();
let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0), (5.0, 20.0, 0.0)];
let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect();
let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0);
lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(10.0 0.0)) '(12.0 6.0) \"\")", p.hex()));
let (EntityKind::LwPolyline(k), t) = last(&s) else { panic!() };
assert!(close(k.elevation, 12.5) && close(t, 1.0), "{} {t}", k.elevation);
let first = k.vertices.first().unwrap().p;
assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}");
}
+12 -2
View File
@@ -72,7 +72,7 @@ fn each_save_keeps_the_previous_file_as_bak() {
add_circle(&mut s, 5.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]);
assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save");
add_circle(&mut s, 10.0);
@@ -118,7 +118,7 @@ fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() {
add_circle(&mut s, 15.0);
s.execute("qsave", &json!({})).unwrap();
assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1));
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -294,3 +294,13 @@ fn the_backup_a_save_promises_is_the_one_it_writes() {
assert!(!save_keeps_current(&path), "backups off");
let _ = std::fs::remove_dir_all(&dir);
}
/// `plan.dwg` and `plan.dxf` in one folder keep their own backups, in the DWG's letter case.
#[test]
fn drawings_sharing_a_name_keep_their_own_backups() {
let name = |p: &str| backup_path(std::path::Path::new(p)).to_string_lossy().into_owned();
assert_eq!(name("x/plan.dwg"), "x/plan.bak");
assert_eq!(name("x/plan.DWG"), "x/plan.BAK");
assert_eq!(name("x/plan.dxf"), "x/plan.dxf.bak");
assert_eq!(name("x/plan.dwt"), "x/plan.dwt.bak");
}
+87
View File
@@ -0,0 +1,87 @@
//! WBLOCK writes the source drawing's units: `$INSUNITS` and `$MEASUREMENT` of the written file
//! are the source's, for selected objects and for a block, as AutoCAD's `-WBLOCK` does. So are its
//! other settings and styles: a metric drawing's text size, dimension style and scale list.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::Drawing;
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
/// Writes "INSUNITS;MEASUREMENT" and, on a second line, the settings a metric drawing changes, so
/// the test can read them from the file that would be saved.
fn write(d: &Drawing, _path: &str) -> Result<Vec<u8>, String> {
Ok(format!("{};{}\n{}", d.header.i64("INSUNITS", -1), d.header.i64("MEASUREMENT", -1), settings(d)).into_bytes())
}
fn settings(d: &Drawing) -> String {
let mleader = d.mleader_styles.first().map(|m| format!("{m:?}")).unwrap_or_default();
let table = d.table_styles.first().map(|t| format!("{t:?}")).unwrap_or_default();
let limmax = d.header.point("LIMMAX");
format!("{} {} {limmax:?} {} {mleader} {table}", d.header.str("DIMSTYLE", ""), d.header.f64("TEXTSIZE", -1.0), d.scales.len())
}
/// The file WBLOCK would save: line `n` of what [`write`] wrote. Each call writes into a folder
/// of its own, so tests running in parallel never read or delete each other's file.
fn wblock(s: &mut Session, p: serde_json::Value, n: usize) -> String {
let dir = TempDir::new("wblock-units");
let path = dir.path_str("out.dxf");
let mut p = p;
p["path"] = json!(path);
s.execute("wblock", &p).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
text.lines().nth(n).unwrap_or("").to_string()
}
fn written(s: &mut Session, p: serde_json::Value) -> String {
wblock(s, p, 0)
}
fn written_settings(s: &mut Session, p: serde_json::Value) -> String {
wblock(s, p, 1)
}
fn line(s: &mut Session) -> String {
s.execute("line", &json!({ "points": [[0, 0], [2, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string()
}
#[test]
fn wblock_keeps_the_source_drawing_units() {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
let mut s = Session::new();
// A metric drawing in metres, like the issue's metres.dxf (`--metric` then `insunits 6`).
s.new_drawing(true);
s.execute("units", &json!({ "insunits": 6 })).unwrap();
let b = line(&mut s);
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [b], "keep": "delete" })).unwrap();
let c = line(&mut s);
// Selected objects and a block by name both keep the source's units, not inches.
assert_eq!(written(&mut s, json!({ "handles": [c] })), "6;1");
assert_eq!(written(&mut s, json!({ "name": "B" })), "6;1");
// The metric default (millimetres) and an imperial drawing keep their units too.
s.new_drawing(true);
let m = line(&mut s);
assert_eq!(written(&mut s, json!({ "handles": [m] })), "4;1");
s.new_drawing(false);
let i = line(&mut s);
assert_eq!(written(&mut s, json!({ "handles": [i] })), "1;0");
}
#[test]
fn wblock_keeps_the_source_drawing_settings_and_styles() {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
let mut s = Session::new();
// A metric drawing: ISO-25, 2.5 mm text, A3 limits, metric multileader/table styles and scales.
s.new_drawing(true);
let source = settings(s.doc().unwrap());
let l = line(&mut s);
assert_eq!(written_settings(&mut s, json!({ "handles": [l] })), source);
s.new_drawing(false);
assert_ne!(settings(s.doc().unwrap()), source, "the imperial template differs, so the check means something");
}
+18 -2
View File
@@ -9,6 +9,12 @@ pub struct Bounds2 {
pub max: Vec2,
}
/// `max - min`, clamped to `f64::MAX` when finite endpoints make it overflow.
fn span(min: f64, max: f64) -> f64 {
let d = max - min;
if d.is_infinite() && min.is_finite() && max.is_finite() { f64::MAX } else { d }
}
impl Default for Bounds2 {
fn default() -> Self {
Bounds2::EMPTY
@@ -47,10 +53,10 @@ impl Bounds2 {
Bounds2 { min: self.min.min(o.min), max: self.max.max(o.max) }
}
pub fn width(&self) -> f64 {
if self.is_empty() { 0.0 } else { self.max.x - self.min.x }
if self.is_empty() { 0.0 } else { span(self.min.x, self.max.x) }
}
pub fn height(&self) -> f64 {
if self.is_empty() { 0.0 } else { self.max.y - self.min.y }
if self.is_empty() { 0.0 } else { span(self.min.y, self.max.y) }
}
pub fn center(&self) -> Vec2 {
if self.is_empty() {
@@ -98,4 +104,14 @@ mod tests {
assert_eq!(b.center().y, 5.0);
assert_eq!(Bounds2::EMPTY.center(), Vec2::ZERO);
}
#[test]
fn width_and_height_stay_finite_for_opposite_large_coordinates() {
let b = Bounds2::new(Vec2::new(-1e308, -1e308), Vec2::new(1e308, 1e308));
assert_eq!(b.width(), f64::MAX);
assert_eq!(b.height(), f64::MAX);
let b = Bounds2::new(Vec2::new(1e308, 0.0), Vec2::new(1.6e308, 10.0));
assert!((b.width() / 1e308 - 0.6).abs() < 1e-12);
assert_eq!(b.height(), 10.0);
}
}
+11 -1
View File
@@ -201,7 +201,17 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
})
}
"LEADER" => EntityKind::Leader(Leader {
vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(),
// Each group 10 starts a vertex. Scope Y/Z to it so an omitted Z cannot
// shift a later vertex's elevation onto this one.
vertices: t
.all_f(10)
.into_iter()
.zip(tags.split(|tag| tag.code == 10).skip(1))
.map(|(x, vertex)| {
let v = T(vertex);
Vec3::new(x, v.fd(20, 0.0), v.fd(30, 0.0))
})
.collect(),
arrow: t.i(71).unwrap_or(1) != 0,
spline: t.i(72).unwrap_or(0) != 0,
style: t.s(3).unwrap_or_else(|| "Standard".into()),
@@ -0,0 +1,106 @@
//! LEADER vertex elevations survive the shared DXF read path and DWG bridge (#2245).
use cadcraft_color::Color;
use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space};
use cadcraft_dxf::Tag;
use cadcraft_geom::Vec3;
use cadcraft_io::{SaveOptions, Version, read, write_with};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
fn drawing() -> TestResult<Drawing> {
let mut d = Drawing::new_metric();
d.dim_styles.push(DimStyle { name: "Elevation".into(), ..DimStyle::default() });
let common = Common { color: Color::Index(3), lineweight: Lineweight::Mm100(25), ltscale: 2.5, visible: false, ..Common::default() };
for vertices in [
vec![Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)],
vec![Vec3::new(1.0, 2.0, 0.0), Vec3::new(5.0, 6.0, 0.0), Vec3::new(9.0, 10.0, 0.0)],
] {
for arrow in [false, true] {
for spline in [false, true] {
let leader = Leader { vertices: vertices.clone(), arrow, spline, style: "Elevation".into() };
d.add(&Space::Model, common.clone(), EntityKind::Leader(leader))?;
}
}
}
Ok(d)
}
fn check_round_trips(name: &str, binary: bool) -> TestResult {
let original = drawing()?;
for version in Version::ALL {
let mut d = original.clone();
for pass in 1..=2 {
let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary })?;
d = read(&bytes, name)?;
assert_eq!(d.model.len(), original.model.len(), "{name} {version:?}, pass={pass}");
for expected in original.model.iter() {
let actual = d.entity(expected.handle).ok_or("LEADER handle lost")?;
let (EntityKind::Leader(want), EntityKind::Leader(got)) = (&expected.kind, &actual.kind) else {
return Err("LEADER entity type changed".into());
};
assert_eq!(got.vertices, want.vertices, "XYZ/order: {name} {version:?}, pass={pass}, {:?}", expected.handle);
assert_eq!((got.arrow, got.spline, &got.style), (want.arrow, want.spline, &want.style), "LEADER properties");
assert_eq!(actual.common, expected.common, "Common properties: {name} {version:?}, pass={pass}");
}
}
}
Ok(())
}
#[test]
fn dxf_preserves_leader_vertex_elevations() -> TestResult {
for binary in [false, true] {
check_round_trips("leader.dxf", binary)?;
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_preserves_leader_vertex_elevations() -> TestResult {
check_round_trips("leader.dwg", false)
}
#[test]
fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult {
let vertices = [Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)];
// All eight presence patterns include wholly 2D input and omitted first/middle/last Zs.
// Author the groups directly, independently of CADCraft's entity writer.
for z_mask in 0..8 {
let mut tags = vec![
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "LEADER"),
Tag::s(5, "10"),
Tag::s(100, "AcDbEntity"),
Tag::s(8, "0"),
Tag::s(100, "AcDbLeader"),
Tag::s(3, "Standard"),
Tag::i(71, 0),
Tag::i(72, 1),
Tag::i(76, 3),
];
let mut expected = Vec::new();
for (i, p) in vertices.iter().enumerate() {
tags.push(Tag::f(10, p.x));
let has_z = z_mask & (1 << i) != 0;
if has_z {
// Y and Z can appear in either order within this vertex.
tags.push(Tag::f(30, p.z));
}
tags.push(Tag::f(20, p.y));
expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 }));
}
tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
let dxf = cadcraft_dxf::write_ascii(&tags);
let d = read(dxf.as_bytes(), "missing-z.dxf")?;
let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?;
let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) };
assert_eq!(l.vertices, expected, "Z presence mask {z_mask:03b}");
assert!(!l.arrow && l.spline);
assert_eq!(l.style, "Standard");
assert_eq!(e.common, Common::default());
}
Ok(())
}
+1
View File
@@ -33,6 +33,7 @@ mod hatch_pattern_def;
mod helix_roundtrip;
mod layout_paper_units;
mod layout_plot_window;
mod leader_vertex_elevation;
#[cfg(not(target_arch = "wasm32"))]
mod low_handseed;
mod mleader_content;
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
//! entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
+3 -5
View File
@@ -26,7 +26,7 @@ mod widgets;
use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor};
use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle};
pub(crate) use draw::{Look, draw_highlight, draw_list};
use draw::{draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill};
use draw::{dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill};
use dyn_boxes::dynamic_input;
pub(crate) use dyn_boxes::length_units;
use grid::draw_grid;
@@ -982,8 +982,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)),
xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)),
);
let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()];
painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0));
painter.extend(dashed_rect(painter.clip_rect(), pa, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0));
(Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper))
}
None => {
@@ -1112,8 +1111,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
}
let st = Stroke::new(1.0, if crossing { t.crossing_stroke } else { t.window_stroke });
if crossing {
let pts = vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
painter.extend(Shape::dashed_line(&pts, st, 5.0, 3.0));
painter.extend(dashed_rect(painter.clip_rect(), r, st, 5.0, 3.0));
} else {
painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle);
}
+196 -5
View File
@@ -19,6 +19,64 @@ fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]>
Some([xf.to_screen(a), xf.to_screen(b)])
}
/// Points past the view's edge that a dashed outline is still drawn to: more than one dash
/// period, so a dash cut at the clip edge never shows.
const CLIP_MARGIN: f32 = 16.0;
/// The parts of a world-space polyline within `xf`'s view, as screen points, each with how far
/// along the whole polyline it starts (in points). Clipped in world coordinates, so an outline far
/// larger than the screen costs only what can show, and its points stay precise when zoomed far
/// in: dashing a whole line made billions of dashes (#2299).
fn visible_runs(xf: &Xf, pts: &[Vec2]) -> Vec<(Vec<Pos2>, f64)> {
let vis = xf.world_bounds().expand(f64::from(CLIP_MARGIN) / xf.scale);
let mut runs: Vec<(Vec<Pos2>, f64)> = Vec::new();
// World length before the current segment; whether the last run reaches its start.
let (mut along, mut open) = (0.0, false);
for w in pts.windows(2) {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
match cadcraft_render::clip::clip_segment(a, b, &vis) {
Some((p, q)) => {
match runs.last_mut() {
Some((run, _)) if open && p == a => run.push(xf.to_screen(q)),
_ => runs.push((vec![xf.to_screen(p), xf.to_screen(q)], (along + a.dist(p)) * xf.scale)),
}
open = q == b;
}
None => open = false,
}
along += a.dist(b);
}
runs
}
/// Dashes along a screen polyline that starts `along` points into the outline it is part of, so
/// they sit where they would on the whole outline.
fn dashes(run: &[Pos2], along: f64, stroke: Stroke, dash: f32, gap: f32, out: &mut Vec<Shape>) {
let period = f64::from(dash + gap);
let offset = (period - along.rem_euclid(period)) % period;
out.extend(Shape::dashed_line_with_offset(run, stroke, &[dash], &[gap], offset as f32));
}
/// A dashed rectangle outline, each edge clipped to `clip` first: a rectangle zoomed far larger
/// than the screen (a layout's printable area, a selection window) gets only the dashes that can
/// show (#2299). Each edge keeps its direction and its dashes start where they would on the whole
/// edge, so they don't crawl as the rectangle or the view moves.
pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f32) -> Vec<Shape> {
let v = |p: Pos2| Vec2::new(f64::from(p.x), f64::from(p.y));
let c = clip.expand(CLIP_MARGIN);
let c = Bounds2::new(v(c.min), v(c.max));
let mut out = Vec::new();
let edges =
[(r.left_top(), r.right_top()), (r.right_top(), r.right_bottom()), (r.right_bottom(), r.left_bottom()), (r.left_bottom(), r.left_top())];
for (a, b) in edges {
if let Some((p, q)) = cadcraft_render::clip::clip_segment(v(a), v(b), &c) {
let pos = |w: Vec2| Pos2::new(w.x as f32, w.y as f32);
dashes(&[pos(p), pos(q)], v(a).dist(p), stroke, dash, gap, &mut out);
}
}
out
}
/// Draw a display list.
pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) {
let vis = xf.world_bounds();
@@ -279,25 +337,31 @@ impl Look {
/// outlined, since filling it would hide the drawing under it.
pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) {
let stroke = Stroke::new(look.width, color);
let line = |pts: Vec<Pos2>, out: &mut Vec<Shape>| {
let screen_line = |pts: Vec<Pos2>, along: f64, out: &mut Vec<Shape>| {
if look.dashed {
out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0));
dashes(&pts, along, stroke, 6.0, 4.0, out);
} else {
out.push(Shape::line(pts, stroke));
}
};
// Only the part of an outline in view (#2299).
let line = |pts: &[Vec2], out: &mut Vec<Shape>| {
for (run, along) in visible_runs(xf, pts) {
screen_line(run, along, out);
}
};
let mut shapes = Vec::new();
let mut fill = egui::Mesh::default();
let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA);
for prim in prims.iter().filter_map(|&i| list.prims.get(i)) {
let pts = list.points(prim);
match prim.kind {
Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes),
Kind::Polyline => line(pts, &mut shapes),
Kind::Infinite { ray } => {
if let (Some(b), Some(d)) = (pts.first(), pts.get(1))
&& let Some(seg) = clip_infinite(xf, *b, *d, ray)
{
line(seg.to_vec(), &mut shapes);
screen_line(seg.to_vec(), 0.0, &mut shapes);
}
}
Kind::Point => {
@@ -314,7 +378,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri
}
Kind::Tris | Kind::Mask => {
for outline in list.fill_outlines(prim) {
line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes);
line(&outline, &mut shapes);
}
}
}
@@ -422,3 +486,130 @@ mod highlight_stability_tests {
}
}
}
#[cfg(test)]
mod zoomed_highlight_tests {
use super::*;
use egui::pos2;
/// A one-segment polyline from `a` to `b`.
fn line(a: Vec2, b: Vec2) -> DisplayList {
let mut list = DisplayList { verts: vec![a, b], ..Default::default() };
list.prims.push(cadcraft_render::DPrim {
handle: Handle(256),
color: Rgb(255, 255, 255),
aci7: true,
lw: 0.0,
transparency: 0,
kind: Kind::Polyline,
start: 0,
len: 2,
});
list
}
const SCREEN: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 600.0, y: 400.0 } };
/// The dash segments `draw_highlight` makes for `list` in `xf`.
fn dashes_of(list: &DisplayList, xf: &Xf) -> Vec<[Pos2; 2]> {
let ctx = egui::Context::default();
let input = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() };
let mut output = ctx.run_ui(input, |ui| draw_highlight(ui.painter(), xf, list, &[0], Color32::LIGHT_BLUE, Look { width: 1.0, dashed: true }));
output.textures_delta.clear();
output
.shapes
.iter()
.filter_map(|s| match &s.shape {
Shape::LineSegment { points, .. } => Some(*points),
_ => None,
})
.collect()
}
/// Zoomed far in on a line, the highlight dashes only what shows. Dashing the whole line made
/// one shape per 10 points of it: billions at the zoom limit, so the app froze and then ran out
/// of memory (#2299).
#[test]
fn a_zoomed_in_highlight_dashes_only_what_shows() {
let list = line(Vec2::new(-1000.0, 0.0), Vec2::new(1000.0, 0.0));
// The smaller zoom first, so a regression fails here instead of filling memory at 1e9.
for scale in [1e3, 1e9] {
let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale }).len();
assert!((50..=70).contains(&n), "scale {scale}: {n} dashes for 600 points of line");
}
}
/// A line running off screen keeps its dashes where they are on the whole line, so they don't
/// crawl along it when the view pans.
#[test]
fn clipped_dashes_stay_where_the_whole_line_puts_them() {
let list = line(Vec2::new(0.0, 0.0), Vec2::new(1000.0, 0.0));
for x in [500.3, 512.9, 731.0] {
let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0 };
let whole = Shape::dashed_line(
&[xf.to_screen(Vec2::ZERO), xf.to_screen(Vec2::new(1000.0, 0.0))],
Stroke::new(1.0, Color32::LIGHT_BLUE),
6.0,
4.0,
);
let on_screen = |p: &[Pos2; 2]| p.iter().all(|q| SCREEN.contains(*q));
let expected: Vec<[Pos2; 2]> = whole
.iter()
.filter_map(|s| match s {
Shape::LineSegment { points, .. } => Some(*points).filter(on_screen),
_ => None,
})
.collect();
let got: Vec<[Pos2; 2]> = dashes_of(&list, &xf).into_iter().filter(on_screen).collect();
assert!(!expected.is_empty());
assert_eq!(got.len(), expected.len(), "centre {x}");
for (g, e) in got.iter().zip(&expected) {
assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "centre {x}: {g:?} vs {e:?}");
}
}
}
/// A rectangle edge running off screen keeps its dashes where they are on the whole edge.
#[test]
fn clipped_rectangle_edges_keep_their_dashes() {
let stroke = Stroke::new(1.0, Color32::GRAY);
let segments = |shapes: Vec<Shape>| -> Vec<[Pos2; 2]> {
shapes
.into_iter()
.filter_map(|s| match s {
// The top edge's dashes that are wholly on screen.
Shape::LineSegment { points, .. } if points.iter().all(|q| SCREEN.contains(*q) && q.y == 100.0) => Some(points),
_ => None,
})
.collect()
};
for left in [-500.3, -517.9, -1200.0] {
let r = Rect::from_min_max(pos2(left, 100.0), pos2(400.0, 300.0));
let expected = segments(Shape::dashed_line(&[r.left_top(), r.right_top()], stroke, 4.0, 4.0));
let got = segments(dashed_rect(SCREEN, r, stroke, 4.0, 4.0));
assert!(!expected.is_empty());
assert_eq!(got.len(), expected.len(), "left edge at {left}");
for (g, e) in got.iter().zip(&expected) {
assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "left edge at {left}: {g:?} vs {e:?}");
}
}
}
/// A dashed rectangle far larger than the screen gets dashes only along the edges that show.
#[test]
fn a_huge_dashed_rectangle_dashes_only_what_shows() {
let stroke = Stroke::new(1.0, Color32::GRAY);
// Its left edge runs through the screen; the other three are far off it. The smaller one
// first, so a regression fails here instead of filling memory with the larger one.
for far in [1e6, 1e9] {
let huge = Rect::from_min_max(pos2(300.0, -far), pos2(far, far));
let n = dashed_rect(SCREEN, huge, stroke, 4.0, 4.0).len();
assert!((50..=60).contains(&n), "{far}: {n} dashes along 432 points of edge");
}
// A rectangle inside the screen keeps all four edges.
let small = Rect::from_min_max(pos2(100.0, 100.0), pos2(180.0, 140.0));
assert_eq!(dashed_rect(SCREEN, small, stroke, 4.0, 4.0).len(), 10 + 5 + 10 + 5);
// Off screen, nothing.
assert!(dashed_rect(SCREEN, small.translate(egui::vec2(5000.0, 0.0)), stroke, 4.0, 4.0).is_empty());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! build.rs from contributors/contributors.json; nothing is
//! read at run time.
use std::cmp::Ordering;
+21 -2
View File
@@ -47,10 +47,12 @@ pub enum Preference {
Turkish,
#[serde(rename = "bs")]
Bosnian,
#[serde(rename = "vi")]
Vietnamese,
}
impl Preference {
pub const ALL: [Self; 18] = [
pub const ALL: [Self; 19] = [
Self::Auto,
Self::English,
Self::Greek,
@@ -69,6 +71,7 @@ impl Preference {
Self::Hungarian,
Self::Turkish,
Self::Bosnian,
Self::Vietnamese,
];
/// Every preference in the order the Interface Language dialog lists them: Auto, then English
@@ -117,6 +120,7 @@ impl Preference {
Self::Hungarian => "hu",
Self::Turkish => "tr",
Self::Bosnian => "bs",
Self::Vietnamese => "vi",
}
}
@@ -146,6 +150,14 @@ impl Preference {
Self::Hungarian => "Magyar",
Self::Turkish => "Türkçe",
Self::Bosnian => "Bosanski",
// Its letters need a font egui doesn't bundle ([`crate::theme::vietnamese_font`]).
Self::Vietnamese => {
if crate::theme::shows_vietnamese(CURRENT.with(Cell::get)) {
"Tiếng Việt"
} else {
"Vietnamese"
}
}
}
}
@@ -177,6 +189,7 @@ impl Preference {
Self::Hungarian => "hu",
Self::Turkish => "tr",
Self::Bosnian => "bs",
Self::Vietnamese => "vi",
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
}
}
@@ -193,7 +206,7 @@ pub struct Language {
catalog: OnceLock<Catalog<'static>>,
}
pub static LANGUAGES: [Language; 17] = [
pub static LANGUAGES: [Language; 18] = [
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
// Greek: one for exactly 1; other for zero and all other integer counts.
Language {
@@ -243,6 +256,10 @@ pub static LANGUAGES: [Language; 17] = [
// Bosnian (Latin script, Ijekavian): one (1, 21), few (2–4, 22–24, not 12–14), other — the
// Ukrainian rule for integer counts.
Language { code: "bs", name: "Bosanski", source: include_str!("i18n/bs.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
// Vietnamese doesn't inflect a noun for number ("3 commit"), so like Korean it has one form.
Language {
code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new()
},
];
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
@@ -447,6 +464,8 @@ mod picker_order_tests {
assert!(pos(Preference::Italian) < pos(Preference::Hungarian), "Magyar sorts under M");
assert!(pos(Preference::Turkish) < pos(Preference::Greek), "Latin names come before Greek");
assert!(pos(Preference::Bosnian) < pos(Preference::Czech), "Bosanski sorts under B");
assert!(pos(Preference::Swedish) < pos(Preference::Vietnamese), "Tiếng Việt sorts under T");
assert!(pos(Preference::Vietnamese) < pos(Preference::Turkish), "Tiếng comes before Türkçe");
assert_eq!(name_sort_key("Türkçe"), "turkce");
}
}
+33 -3
View File
@@ -63,6 +63,8 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec<String> {
let catalog = catalog(language).unwrap();
let mut sources = BTreeSet::new();
sources.extend(crate::menus::MENUS.iter().copied());
// File ▸ Open Recent is built at run time, not from a command's menu path.
sources.extend([crate::menus::OPEN_RECENT, crate::menus::CLEAR_RECENT]);
for c in cadcraft_engine::command_specs() {
sources.insert(c.label);
sources.extend(c.menu.iter().copied());
@@ -187,6 +189,7 @@ fn assert_additional_locale_aliases() {
("hu", &["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu", " hu-HU "]),
("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", " tr-TR ", "tr-CY"]),
("bs", &["bs", "bs-BA", "bs_BA.UTF-8", "BS-ba", " bs-BA ", "bs-Latn-BA"]),
("vi", &["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn", " vi-VN "]),
("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]),
("zh", &["zh", "zh-CN"]),
] {
@@ -432,12 +435,13 @@ fn dialogs_render_in_every_language_and_keep_their_source_ids() {
}
/// The letters of every catalog have glyphs in both UI families. Latin, Cyrillic and Greek come
/// from egui's bundled fonts alone, as in the web build; the scripts they lack need a font that
/// `install_fonts` finds, and are checked only where one is there.
/// from egui's bundled fonts alone, as in the web build; the scripts they lack (and Vietnamese's
/// tone-marked letters) need a font that `install_fonts` finds, and are checked only where one is
/// there.
#[test]
fn interface_fonts_cover_every_letter_in_each_catalog() {
for language in translated() {
let extra_font = ["ko", "zh", "ar"].contains(&language.code);
let extra_font = ["ko", "zh", "ar", "vi"].contains(&language.code);
if extra_font && !crate::theme::can_show(language.code) {
continue;
}
@@ -558,6 +562,13 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() {
});
}
/// #1129: in Chinese every menu, submenu, command, tool palette and object-snap label is
/// translated, so no menu shows English entries.
#[test]
fn chinese_menus_and_commands_are_fully_translated() {
assert_eq!(missing_commands_menus_tools_and_snap_labels("zh"), Vec::<String>::new());
}
/// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`.
#[test]
fn bosnian_counts_take_one_few_other() {
@@ -566,3 +577,22 @@ fn bosnian_counts_take_one_few_other() {
assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian));
assert_eq!(Preference::Bosnian.native_name(), "Bosanski");
}
/// Vietnamese has one form for every count, and shows only with an interface font that has its
/// tone-marked letters; without one it stays English and says why.
#[test]
fn vietnamese_takes_one_form_and_needs_a_font_with_its_letters() {
assert_eq!(Preference::parse("vi"), Some(Preference::Vietnamese));
assert_eq!(Preference::Vietnamese.native_name(), "Tiếng Việt");
let vi = LANGUAGES.iter().find(|l| l.code == "vi").unwrap();
assert_eq!(vi.plural_forms, 1);
assert!([0, 1, 2, 5, 21, 111].into_iter().all(|n| (vi.plural)(n) == 0));
let mut app = CadApp::new(Session::new(), Services::default());
let r = app.run("ui.language", json!({"lang": "vi"})).unwrap();
let shown = crate::theme::can_show("vi");
assert_eq!(r["shown"], if shown { "vi" } else { "en" });
if !shown {
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("Vietnamese letters")));
assert_eq!(Preference::Vietnamese.name(), "Vietnamese");
}
}
+32 -32
View File
@@ -130,8 +130,8 @@
Boundary Sınır
Boundary... Sınır...
Box Kutu
Break Kes
Break At Point Noktadan Kes
Break Kır
Break At Point Noktadan Kır
Bring Above Objects Nesnelerin Üstüne Getir
Bring Annotations to Front Açıklamaları Öne Getir
Bring to Front En Öne Getir
@@ -281,7 +281,7 @@
Diameter Çap
Dim style Ölçü stili
Dimension Ölçü
Dimension Break Ölçü Kesme
Dimension Break Ölçü Kırma
Dimension Edit Ölçü Düzenleme
Dimension Objects Only Yalnızca Ölçülendirme Nesneleri
Dimension Space Ölçü Aralığı
@@ -295,7 +295,7 @@
Disassociate Dimensions Ölçülerin İlişkisini Kaldır
Display Görüntü
Display name Görünen ad
Display plot styles Çizim stillerini göster
Display plot styles Çizdirme stillerini göster
Distance Uzaklık
Divide Böl
Do not plot (click: plot) Çizdirme (tıklayın: çizdir)
@@ -333,7 +333,7 @@
Edit Reference In-Place Referansı Yerinde Düzenle
Edit Table Cell Tablo Hücresini Düzenle
Edit Text... Metni Düzenle...
Elevation Yükseklik
Elevation Kot
Ellipse Elips
Elliptical Arc Eliptik Yay
Enable 3D mouse navigation 3B fare ile gezinmeyi etkinleştir
@@ -360,7 +360,7 @@
Enter constraint type Kısıtlama türünü girin
Enter continuity Süreklilik girin
Enter default attribute value Varsayılan öznitelik değerini girin
Enter degree of spline Eğrinin derecesini girin
Enter degree of spline Spline derecesini girin
Enter delta angle Açı farkını girin
Enter delta length or Uzunluk farkını girin veya
Enter dimension text Ölçü metnini girin
@@ -427,7 +427,7 @@
Freeze/Thaw Dondur/Çöz
Freeze/Thaw in all viewports Tüm görünüm pencerelerinde dondur/çöz
Freeze/Thaw in the current viewport Geçerli görünüm penceresinde dondur/çöz
From Kimden
From Kaynak
From Template... Şablondan...
General Genel
Geometric Center Geometrik Merkez
@@ -487,7 +487,7 @@
Hide Palettes Paletleri Gizle
History Geçmiş
Hold Shift to add to the selection Seçime eklemek için Shift'i basılı tutun
Home Giriş
Home Başlangıç
Horizontal Yatay
HorizontalDistance Yatay Uzaklık
How to apply: Uygulama biçimi:
@@ -677,7 +677,7 @@
No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş.
No external references are attached. İliştirilmiş dış referans yok.
No model credits were built into this copy. Bu kopyaya model emeği bilgisi eklenmemiş.
No plot style tables. Çizim stili tablosu yok.
No plot style tables. Çizdirme stili tablosu yok.
No saved layer states Kayıtlı katman durumu yok
No selection Seçim yok
No supported 3D mouse found Desteklenen 3B fare bulunamadı
@@ -763,16 +763,16 @@
Pickbox size Seçim kutusu boyutu
Plot Çizdir
Plot (click: do not plot) Çizdir (tıklayın: çizdirme)
Plot Preview Çizim Önizlemesi
Plot Style Manager Çizim Stili Yöneticisi
Plot Style Manager... Çizim Stili Yöneticisi...
Plot Style... Çizim Stili...
Plot Styles... Çizim Stilleri...
Plot Window Çizim Penceresi
Plot style Çizim stili
Plot style attached to Çizim stilinin bağlı olduğu yer
Plot style table Çizim stili tablosu
Plot table type Çizim tablosu türü
Plot Preview Çizdirme Önizlemesi
Plot Style Manager Çizdirme Stili Yöneticisi
Plot Style Manager... Çizdirme Stili Yöneticisi...
Plot Style... Çizdirme Stili...
Plot Styles... Çizdirme Stilleri...
Plot Window Çizdirme Penceresi
Plot style Çizdirme stili
Plot style attached to Çizdirme stilinin bağlı olduğu yer
Plot style table Çizdirme stili tablosu
Plot table type Çizdirme tablosu türü
Plotter Manager Çizici Yöneticisi
Plotter Manager... Çizici Yöneticisi...
Plus/minus Artı/eksi
@@ -913,7 +913,7 @@
Select a block reference. Bir blok referansı seçin.
Select a circle or arc. Bir daire veya yay seçin.
Select a closed polyline Kapalı bir çoklu çizgi seçin
Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı eğri seçin.
Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı spline seçin.
Select a closed polyline. Kapalı bir çoklu çizgi seçin.
Select a curve as the path. Yol olarak bir eğri seçin.
Select a dimension constraint type Bir ölçü kısıtlaması türü seçin
@@ -922,7 +922,7 @@
Select a line or an arc. Bir çizgi veya yay seçin.
Select a line or polyline segment Bir çizgi veya çoklu çizgi parçası seçin
Select a line, arc or open polyline. Bir çizgi, yay veya açık çoklu çizgi seçin.
Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya eğri seçin.
Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya spline seçin.
Select a line, circle, arc or polyline segment. Bir çizgi, daire, yay veya çoklu çizgi parçası seçin.
Select a line. Bir çizgi seçin.
Select a polyline. Bir çoklu çizgi seçin.
@@ -935,7 +935,7 @@
Select an object to measure or Ölçülecek nesneyi seçin veya
Select an object. Bir nesne seçin.
Select an open curve. Açık bir eğri seçin.
Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya eğri seçin.
Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya spline seçin.
Select arc or circle Yay veya daire seçin
Select arc or polyline arc segment Yay veya çoklu çizgi yay parçası seçin
Select arc style Yay stilini seçin
@@ -952,10 +952,10 @@
Select object Nesne seçin
Select object to dimension Ölçülendirilecek nesneyi seçin
Select object to divide Bölünecek nesneyi seçin
Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da kırpmak için Shift ile seçin
Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da budamak için Shift ile seçin
Select object to measure Ölçülecek nesneyi seçin
Select object to offset Ötelenecek nesneyi seçin
Select object to trim or shift-select to extend Kırpılacak nesneyi seçin ya da uzatmak için Shift ile seçin
Select object to trim or shift-select to extend Budanacak nesneyi seçin ya da uzatmak için Shift ile seçin
Select objects Nesneleri seçin
Select objects (crossing window first corner) Nesneleri seçin (kesişen pencerenin ilk köşesi)
Select objects first, then choose a command Önce nesneleri seçin, sonra bir komut belirleyin
@@ -971,7 +971,7 @@
Select second object İkinci nesneyi seçin
Select second object or shift-select to apply corner İkinci nesneyi seçin ya da köşe uygulamak için Shift ile seçin
Select second point İkinci noktayı seçin
Select spline Eğri seçin
Select spline Spline seçin
Select symmetry line Simetri çizgisini seçin
Select two points İki nokta seçin
Selection Seçim
@@ -1057,7 +1057,7 @@
Specify center point of array Dizinin merkez noktasını belirtin
Specify center point of base Tabanın merkez noktasını belirtin
Specify corner of viewport Görünüm penceresinin köşesini belirtin
Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX or nXP)
Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX veya nXP)
Specify diameter of circle Dairenin çapını belirtin
Specify dimension arc line location Ölçü yayı çizgisinin konumunu belirtin
Specify dimension line location Ölçü çizgisinin konumunu belirtin
@@ -1125,7 +1125,7 @@
Specify rotation angle or Döndürme açısını belirtin veya
Specify scale factor Ölçek faktörünü belirtin
Specify second angle endpoint Açının ikinci uç noktasını belirtin
Specify second break point İkinci kesme noktasını belirtin
Specify second break point İkinci kırma noktasını belirtin
Specify second chamfer distance for rectangles Dikdörtgenler için ikinci pah uzaklığını belirtin
Specify second constraint point İkinci kısıtlama noktasını belirtin
Specify second destination point İkinci hedef noktayı belirtin
@@ -1148,7 +1148,7 @@
Specify starting width Başlangıç genişliğini belirtin
Specify starting width for next segment Sonraki parçanın başlangıç genişliğini belirtin
Specify tangent direction for the start point of arc Yayın başlangıç noktası için teğet yönünü belirtin
Specify the elevation for rectangles Dikdörtgenler için yüksekliği belirtin
Specify the elevation for rectangles Dikdörtgenler için kotu belirtin
Specify thickness for rectangles Dikdörtgenler için kalınlığı belirtin
Specify third point on circle Daire üzerindeki üçüncü noktayı belirtin
Specify third point on circle: _tan to Daire üzerindeki üçüncü noktayı belirtin: _tan to
@@ -1161,8 +1161,8 @@
Specify width for rectangles Dikdörtgenler için genişliği belirtin
Sphere Küre
Spherical Küresel
Spline Eğri
Spline Edit Eğri Düzenle
Spline Spline
Spline Edit Spline Düzenle
Stack (type 1/2 then select) Yığ (1/2 yazıp seçin)
Standard Standart
Start Başlangıç
@@ -1218,7 +1218,7 @@
This drawing Bu çizim
Time Zaman
Time-sensitive right-click: a quick click is Enter, holding opens the menu Süreye bağlı sağ tıklama: kısa tıklama Enter'dır, basılı tutmak menüyü açar
To Kime
To Hedef
To get started, create or open a drawing. Başlamak için bir çizim oluşturun veya açın.
Toggle PICKADD (off: each pick replaces the selection) PICKADD'i değiştir (kapalı: her seçim öncekinin yerini alır)
Toggle PICKADD (on: picks add to the selection) PICKADD'i değiştir (açık: seçimler mevcut seçime eklenir)
@@ -1233,7 +1233,7 @@
Track orthogonally only Yalnızca dik açılarla izle
Track using all polar angle settings Tüm kutupsal açı ayarlarını kullanarak izle
Transparency Saydamlık
Trim Kırp
Trim Buda
True Color Gerçek Renk
Turn All Layers On Tüm Katmanları Aç
Turn Off Layer Katmanı Kapat
Can't render this file because it contains an unexpected character in line 11 and column 13.
File diff suppressed because it is too large Load Diff
+31
View File
@@ -1349,3 +1349,34 @@
Check for Updates… 检查更新…
CADCraft is up to date. CADCraft 已是最新版本。
Could not check for updates. 无法检查更新。
# Menu and command labels the October 2026 update missed (#1129).
Align Multileaders 对齐多重引线
Analyze Usage 分析使用情况
Break-line Symbol 折断线符号
Change 更改
Change Properties 修改特性
Clear Recent Files 清除最近的文件
Close Block Editor 关闭块编辑器
Collect 合并
Collect Multileaders 合并多重引线
Convert Text to Mtext 将文字转换为多行文字
Customize 自定义
Drag Dynamic Grip 拖动动态夹点
Dynamic Block Data 动态块数据
Dynamic Block Grips 动态块夹点
Dynamic Block Reference 动态块参照
Edit Command Aliases (PGP)... 编辑命令别名 (PGP)...
Edit Multileader 编辑多重引线
Evaluate LISP 求值 LISP
Explode Attributes to Text 将属性分解为文字
Express Tools 扩展工具
Groups 编组
Hatch Pick 拾取图案填充
Implied Loops 隐含环
Justify 对正
Move/Copy/Rotate 移动/复制/旋转
Save Block 保存块
Save Block As 块另存为
Save Layout as Template 将布局另存为样板
Set Dynamic Property 设置动态特性
Units 单位
1 # CADCraft Simplified Chinese interface catalog — original translations.
1349
1350
1351
1352 # Menu and command labels the October 2026 update missed (#1129).
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
+12 -5
View File
@@ -10,6 +10,8 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools"
/// File ▸ Open Recent: one `ui.recent.<n>` item per recent file, then Clear Recent Files.
pub const OPEN_RECENT: &str = "Open Recent";
/// The last item of File ▸ Open Recent.
pub const CLEAR_RECENT: &str = "Clear Recent Files";
const RECENT_ITEM: &str = "ui.recent.";
const RECENT_CLEAR: &str = "ui.recent.clear";
@@ -265,10 +267,15 @@ fn run_language_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
app.ui.interface_language = preference;
let shown = app.language();
if shown != preference.resolve(&app.system_languages) {
app.set_status(if preference.resolve(&app.system_languages) == "ko" {
"No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face."
} else {
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese."
app.set_status(match preference.resolve(&app.system_languages) {
"ko" => {
"No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face."
}
"vi" => {
"No installed interface font has the Vietnamese letters, so the interface stays in English. Install one that does (Segoe UI, Arial, DejaVu Sans or Noto Sans) to use Vietnamese."
}
"ar" => "No Arabic font is installed, so the interface stays in English. Install one (Noto Sans Arabic, for example) to use Arabic.",
_ => "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
});
}
Some(Ok(json!({"interfaceLanguage": preference.code(), "shown": shown})))
@@ -629,7 +636,7 @@ pub fn recent_entries(app: &CadApp) -> Vec<Entry> {
.map(|(i, path)| Entry::Item { label: file_name(path), id: format!("{RECENT_ITEM}{i}"), shortcut: None, enabled: true })
.collect();
let enabled = !app.ui.recent_files.is_empty();
entries.push(Entry::Item { label: "Clear Recent Files".into(), id: RECENT_CLEAR.into(), shortcut: None, enabled });
entries.push(Entry::Item { label: CLEAR_RECENT.into(), id: RECENT_CLEAR.into(), shortcut: None, enabled });
entries
}
+1 -1
View File
@@ -10,7 +10,7 @@
//! |---|---|
//! | `theme` | `"system"`, `"light"` or `"dark"` |
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` or `"el"` |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"`, `"vi"` or `"el"` |
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
+96 -14
View File
@@ -326,19 +326,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
let chinese = language.starts_with("zh");
#[cfg(not(target_arch = "wasm32"))]
{
let candidates: &[&str] = if cfg!(target_os = "macos") {
&["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"]
} else if cfg!(windows) {
&["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"]
} else {
&[
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
]
};
for path in candidates {
for path in system_ui_candidates() {
// A Vietnamese interface takes the first one with its letters.
if language == "vi" && vietnamese_font() != Some(std::path::Path::new(path)) {
continue;
}
if let Ok(bytes) = std::fs::read(path) {
fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
@@ -409,6 +401,82 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
ctx.set_fonts(fonts);
}
/// The system interface fonts [`install_fonts`] looks for, in order; it loads the first installed
/// one ahead of egui's bundled faces.
#[cfg(not(target_arch = "wasm32"))]
fn system_ui_candidates() -> &'static [&'static str] {
if cfg!(target_os = "macos") {
&["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"]
} else if cfg!(windows) {
&["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"]
} else {
&[
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
]
}
}
/// The first of `candidates` that is `installed` and, for the Vietnamese interface, has the
/// Vietnamese letters: the system interface font [`install_fonts`] loads ahead of egui's faces.
#[cfg(not(target_arch = "wasm32"))]
fn ui_font_choice<'a>(
candidates: &[&'a str],
vietnamese: bool,
installed: impl Fn(&str) -> bool,
has_vietnamese: impl Fn(&str) -> bool,
) -> Option<&'a str> {
candidates.iter().copied().find(|p| installed(p) && (!vietnamese || has_vietnamese(p)))
}
/// Whether `face` has every letter Vietnamese adds to Latin: the tone-marked vowels of Latin
/// Extended Additional (U+1EA0–U+1EF9), and ơ, ư, đ.
#[cfg(not(target_arch = "wasm32"))]
fn covers_vietnamese(face: &cadcraft_fonts::ttf::Face) -> bool {
('\u{1ea0}'..='\u{1ef9}').chain(['ơ', 'Ơ', 'ư', 'Ư', 'đ', 'Đ']).all(|c| face.has_glyph(c))
}
/// The system interface font the Vietnamese interface takes: the first of
/// [`system_ui_candidates`] whose first face (the one egui uses) has every Vietnamese letter.
/// Segoe UI, Arial, SF, DejaVu Sans and Noto Sans do; egui's bundled faces stop at Latin
/// Extended-A, so without one the interface stays English (as the web build does). Checked once.
pub fn vietnamese_font() -> Option<&'static std::path::Path> {
#[cfg(not(target_arch = "wasm32"))]
{
static FOUND: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
FOUND
.get_or_init(|| {
let has_vietnamese = |p: &str| {
read_capped(std::path::Path::new(p), MAX_UI_FONT_BYTES)
.and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next())
.is_some_and(|face| covers_vietnamese(&face))
};
ui_font_choice(system_ui_candidates(), true, |p| std::path::Path::new(p).is_file(), has_vietnamese).map(std::path::PathBuf::from)
})
.as_deref()
}
#[cfg(target_arch = "wasm32")]
None
}
/// Whether the interface in `language` shows the Vietnamese letters (Tiếng Việt in the language
/// picker): the Vietnamese interface with [`vietnamese_font`], any other when the system interface
/// font it loads is that one.
pub fn shows_vietnamese(language: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
let loaded = || ui_font_choice(system_ui_candidates(), false, |p| std::path::Path::new(p).is_file(), |_| true);
vietnamese_font().is_some_and(|v| language == "vi" || loaded().is_some_and(|p| std::path::Path::new(p) == v))
}
#[cfg(target_arch = "wasm32")]
{
let _ = language;
false
}
}
/// The faces `build.rs` embedded from craft-fonts that the interface loads, in order: every CJK
/// face, because file, layout and layer names can be Japanese or Chinese whatever the interface
/// shows (#513), with the interface's own face leading. A sans-serif face reads best, and a
@@ -659,10 +727,12 @@ pub fn shows_hangul(language: &str) -> bool {
/// Whether the interface can show `language` without missing-glyph boxes: Chinese needs a font
/// with its glyphs (installed, or the CJK face this build embedded from craft-fonts), Korean a
/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face.
/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face, Tiếng Việt a
/// system interface font with its tone-marked letters ([`vietnamese_font`]).
pub fn can_show(language: &str) -> bool {
match language {
"ko" => shows_hangul("ko"),
"vi" => vietnamese_font().is_some(),
"ar" => arabic_font().is_some() || embedded_script("Arab"),
l if l.starts_with("zh") => cjk_font().is_some() || embedded_script("Hans") || embedded_script("Hant"),
_ => true,
@@ -737,6 +807,18 @@ pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) {
#[cfg(test)]
mod tests {
/// The system interface font loaded: the first installed one, and for Vietnamese the first
/// installed one with its letters (a later one when the first lacks them).
#[test]
fn the_vietnamese_interface_takes_the_first_installed_font_with_its_letters() {
let candidates = ["a.ttf", "b.ttf", "c.ttf", "d.ttf"];
let installed = |p: &str| p != "a.ttf";
let has_vietnamese = |p: &str| p == "c.ttf" || p == "d.ttf";
assert_eq!(super::ui_font_choice(&candidates, false, installed, has_vietnamese), Some("b.ttf"));
assert_eq!(super::ui_font_choice(&candidates, true, installed, has_vietnamese), Some("c.ttf"));
assert_eq!(super::ui_font_choice(&candidates, true, installed, |_| false), None);
}
use cadcraft_fonts::CraftFont;
/// A face shaped like `build.rs` writes them, with no font file (as in `cadcraft-fonts`).
+32 -3
View File
@@ -3,6 +3,7 @@
//! `craft_update::updater::Updater`.
use craft_update::updater::{Action, Status, Updater};
use craft_update::{CheckResultDialog, CheckResultResponse};
use crate::CadApp;
@@ -16,11 +17,15 @@ pub struct Runtime {
enabled: bool,
/// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context.
check_requested: bool,
/// The check-result dialog is open (from a requested check until it is closed).
result_open: bool,
/// The current result was already written to the command history.
result_echoed: bool,
}
impl Default for Runtime {
fn default() -> Self {
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false, result_echoed: false }
}
}
@@ -31,9 +36,11 @@ impl CadApp {
}
}
/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer.
/// Help ▸ Check for Updates…: check now, and show the result when there is nothing newer.
pub fn check_now(app: &mut CadApp) {
app.updates_rt.check_requested = true;
app.updates_rt.result_open = true;
app.updates_rt.result_echoed = false;
}
/// Start the checks and collect the answers.
@@ -50,8 +57,16 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) {
Status::Failed(_) => Some(crate::tl!("Could not check for updates.")),
Status::Idle | Status::Checking => None,
};
if let Some(message) = outcome {
let Some(message) = outcome else {
// A check that found an update leaves `Idle`; the prompt takes over from the dialog.
if rt.result_open && matches!(rt.updater.status(), Status::Idle) {
rt.result_open = false;
}
return;
};
if !rt.result_open {
rt.updater.dismiss_status();
} else if !std::mem::replace(&mut rt.result_echoed, true) {
app.session.echo(message);
}
}
@@ -60,6 +75,20 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) {
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = !app.session.docs.iter().any(|d| d.is_dirty());
let rt = &mut app.updates_rt;
if rt.result_open {
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
CheckResultResponse::Close => {
rt.result_open = false;
rt.updater.dismiss_status();
}
CheckResultResponse::CheckAgain => {
rt.result_echoed = false;
rt.check_requested = true;
}
CheckResultResponse::None => {}
}
}
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
// The guarded close path asks about unsaved changes; the restart helper relaunches once the
+1
View File
@@ -37,3 +37,4 @@ mod toolsets_gear;
mod toolsets_parametric;
mod viewport_double_click_selection;
mod workspace_focus;
mod zoomed_far_in;
+40
View File
@@ -0,0 +1,40 @@
//! Zooming far in with the pointer on an object (#2299): its rollover highlight is dashed, and
//! dashing the whole object made one shape per 10 points of it on screen. Zoomed to the limit
//! that was billions of shapes, so the app froze and then ran out of memory.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
use serde_json::json;
/// One frame of the app; the number of shapes it painted.
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>) -> usize {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out.shapes.len()
}
#[test]
fn zooming_far_in_on_a_hovered_line_paints_what_shows() {
let mut app = CadApp::new(Session::new(), Services::default());
app.run("circle", json!({"center": [50, 50], "radius": 20})).unwrap();
app.run("line", json!({"points": [[0, 0], [100, 100]]})).unwrap();
app.run("line", json!({"points": [[0, 100], [100, 0]]})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, Vec::new());
let over = app.canvas.rect.unwrap().center();
// The smaller zoom first, so a regression fails there instead of filling memory at the limit.
for height in [1e-2, 1e-9] {
// Centred on a point of the first line, which the pointer, at the canvas centre, hovers.
app.run("view.set", json!({"center": [25, 25], "height": height})).unwrap();
let shapes = (0..3).map(|_| frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)])).max().unwrap_or(0);
// Without a hovered object there is no dashed highlight, and the count says nothing.
assert!(app.canvas.hover.is_some(), "view height {height}: the line is hovered");
assert!(shapes < 5_000, "view height {height}: {shapes} shapes painted");
assert_eq!(app.session.state().unwrap().view().height, height);
}
}
+4 -21
View File
@@ -1,16 +1,14 @@
# Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
this page is the local copy of the decision.
the AI models named in `Co-Authored-By` trailers.
## Decision
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
username.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
@@ -32,20 +30,5 @@ display_name = "Nickname"
sync_github_name = true # use the name on your public GitHub profile as display name
```
or, with craftrules checked out next to `craft-apps/`:
```sh
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
```
Names show after the next refresh: the script checks that each entry was committed by its own
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
## Refreshing (maintainers)
```sh
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
```
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
Delete your entry to remove your names. Git author names and emails are never recorded.
+1 -1
View File
@@ -107,7 +107,7 @@ Detail: [localization-parity.md](localization-parity.md).
| # | Gap | Est. |
|---|---|---|
| L1 | Catalog covers the UI (989 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 |
| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi from the standard's list | 35–55 + native review |
| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi | 35–55 + native review |
| L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI. Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 |
| L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 |
+1 -1
View File
@@ -46,7 +46,7 @@ shaping and right-to-left text through TrueType, vertical SHX text styles.
## Languages
Order and columns from craftrules' progress-docs standard. Every UI percentage is **measured**
Every UI percentage is **measured**
from the catalogs (0 rows for every language without a catalog file). Hours are **estimated**:
about 3–5 h per language for an Opus 5.5 agent to translate the ~990 catalog rows and fix layout,
plus native review by a human; extending the catalog to the remaining English-only strings
+1 -1
View File
@@ -1,6 +1,6 @@
# Releasing CADCraft
The canonical recipe is craftrules' [`release/playbook.md`](https://github.com/storytold/craftrules/blob/main/release/playbook.md); this file covers only what's CADCraft-specific.
This file describes how CADCraft is released.
- **Version:** `[workspace.package] version` in the root `Cargo.toml` is the single source. `cargo xtask version` prints it; `cargo xtask version set X.Y.Z[-pre]` changes it. The app shows it in About, `cadcraft --version` and `cadcraft-cli --version`; release builds also embed the commit (`CADCRAFT_BUILD_SHA`).
- **Cut a release:** bump the version on `main`, then push `main` to `release` (`git push origin main:release`). Every push to `release` runs `.github/workflows/release.yml` and creates or updates the draft GitHub Release `CADCraft v<version>`. A `workflow_dispatch` run with `version: X.Y.Z-rc.N` makes a test build. A dispatch on any other branch is a dry run: the unsigned jobs (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) build, the `release` environment refuses the signing jobs (macOS, Windows), and no release is drafted.
+1 -2
View File
@@ -26,8 +26,7 @@ Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows.
## Alpha gate
The core workflows a typical AutoCAD professional does every day (craftrules progress-docs
standard). CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are
The core workflows a typical AutoCAD professional does every day. CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are
the alpha checklist. Main platform: macOS.
| Core workflow | Works end to end? | Evidence | Hours to pass |
+2 -3
View File
@@ -65,8 +65,7 @@ mainstream and essentials numbers use multiplicative discounts.
## Mainstream practitioner
The typical AutoCAD professional is a 2D drafter or detailer: draw, modify, dimension, use
blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded per
the standard: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces,
blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces,
Share), Sheet Set Manager administration, specialist hardware, languages other than English.
| Area (weekly use) | Weight | Depth | Weighted |
@@ -215,7 +214,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
| Date | Feature breadth | Weighted parity | Remaining | Note |
|---|---|---|---|---|
| 2026-10-10 (merge) | unchanged (233/491 re-scanned) | ready ≈ 38% (37.7%), mainstream ≈ 28%, essentials ≈ 46% | unchanged within rounding | main landed dynamic input pointer boxes (#60), the Ukrainian catalog (#36), WIPEOUT/gradient DXF fixes (#147, #162): UI/UX 40% → 43%, localization 3% → 5% |
| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added (craftrules standard) |
| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added |
| 2026-10-10 | 245/491 (50%), engine-only 233/491 | features ≈ 41%, ready for real work ≈ 37% | 1,300–2,100 h | full re-measure in this format (this file) |
| 2026-10-07 | 233/491 (47%) | ≈ 29% overall, 2D ≈ 55% | ≈ 570 h | old ROADMAP: milestone percentages reported by the building agents |
+8
View File
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.cadcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [[ "$OCI" = 1 ]]; then
OUT_OCI="$DIST/$BASENAME-oci"
rm -rf "$OUT_OCI"
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
echo "wrote OCI layout to $OUT_OCI"
fi
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build cadcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
#
# Usage:
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
#
# Environment variables:
# REGISTRY Target registry image (default: ghcr.io/storytold/cadcraft)
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
# GITHUB_TOKEN Token for GHCR authentication when --push is used
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
PUSH=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--push) PUSH=1; shift ;;
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="cadcraft-$VERSION-linux-$ARCH"
OUT_OCI="$DIST/$BASENAME-oci"
echo "==> Building Flatpak OCI layout for $BASENAME"
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
REGISTRY="${REGISTRY:-ghcr.io/storytold/cadcraft}"
TAG="${TAG:-$VERSION-$ARCH}"
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
if [[ "$PUSH" = 1 ]]; then
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
AUTH_ARGS=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
fi
echo "==> Pushing to $REGISTRY:$TAG"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
# Also update architecture-specific stable tag
echo "==> Pushing to $REGISTRY:stable-$ARCH"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
echo "==> Push complete"
fi