mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-08 19:40:30 -06:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4744b4cffb | ||
|
|
d9e62df1dd | ||
|
|
9bd7f8f8f1 | ||
|
|
d847eac134 | ||
|
|
cd71385da9 | ||
|
|
004cbecd48 | ||
|
|
0e7e0f59f0 | ||
|
|
6cb8e9b1a0 | ||
|
|
909af5e389 | ||
|
|
78810069c7 | ||
|
|
bd4a47491a | ||
|
|
54c973c919 | ||
|
|
53962a5cd5 | ||
|
|
1434d1017b | ||
|
|
baf2424164 | ||
|
|
ec09c87c43 | ||
|
|
a0dfe07f5a | ||
|
|
9ec5728159 | ||
|
|
42490afe8e | ||
|
|
adf02f9f04 | ||
|
|
e56cc4beeb | ||
|
|
7a9ec9bae3 | ||
|
|
1363afb883 | ||
|
|
3f9e45f726 | ||
|
|
5ece420543 | ||
|
|
bd37c89ca1 | ||
|
|
62caaa30d9 | ||
|
|
486d558a78 | ||
|
|
6304abd8f0 | ||
|
|
07b6b8c53a | ||
|
|
14a6cc3cdc | ||
|
|
85b35fc5b9 | ||
|
|
865fa9afb0 | ||
|
|
1452ab6f9f | ||
|
|
0140e275f3 | ||
|
|
098fa7fd80 | ||
|
|
32db96f5e3 | ||
|
|
4e36021812 | ||
|
|
fafc9e1910 | ||
|
|
a42760af80 | ||
|
|
b1263ed3a4 | ||
|
|
141a1ac8cd | ||
|
|
42c1dca89d | ||
|
|
b36d7aea40 | ||
|
|
968b5bd648 | ||
|
|
35545af16d | ||
|
|
876e103488 | ||
|
|
01e28fc0d4 | ||
|
|
bd639d3204 | ||
|
|
cce67d27c7 | ||
|
|
d23c5f0182 | ||
|
|
789c4e9542 | ||
|
|
5aa0e2da9e | ||
|
|
cdbc32ac8f | ||
|
|
7e141247dd | ||
|
|
2cbd67f963 | ||
|
|
56dc397680 | ||
|
|
d2052eb4c7 | ||
|
|
0722afc5bd | ||
|
|
5ca4b220aa | ||
|
|
b254a28a5e | ||
|
|
168f4a699a | ||
|
|
ff6b0e375b | ||
|
|
f977b6e7cc | ||
|
|
c9082d974d | ||
|
|
fd3a0b9e15 | ||
|
|
b8efe39acc | ||
|
|
b1bab3043c | ||
|
|
0e949bffe1 | ||
|
|
50bc6c1158 | ||
|
|
f8f0504b9a | ||
|
|
6ecabc3451 | ||
|
|
c223550cb3 | ||
|
|
ef167c3140 | ||
|
|
08ea23ec2d | ||
|
|
98b47c1984 | ||
|
|
1f75a9fb25 | ||
|
|
c2175377fd | ||
|
|
c1d7e4415b | ||
|
|
1e02e6f263 | ||
|
|
d0ab457f31 | ||
|
|
eae1fb98bf | ||
|
|
c35d18e803 | ||
|
|
811bd3cb4e | ||
|
|
603e1acaf9 | ||
|
|
5585298128 | ||
|
|
e67de34cf0 | ||
|
|
ec37f02f46 | ||
|
|
f02ac726ba | ||
|
|
f892a6f60d | ||
|
|
0934a629e6 | ||
|
|
22c6b398fd | ||
|
|
609148a444 | ||
|
|
2830c7c176 | ||
|
|
a841f225cf | ||
|
|
6549b97b6c | ||
|
|
1d29b626e6 | ||
|
|
cab962a7cb | ||
|
|
101397d9c5 | ||
|
|
c32c0f5e30 | ||
|
|
950453c7fd | ||
|
|
7003c5b930 |
@@ -18,6 +18,12 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
@@ -58,7 +64,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
@@ -70,6 +76,7 @@ jobs:
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+15
-1
@@ -728,6 +728,7 @@ dependencies = [
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
@@ -828,9 +829,11 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
@@ -1232,10 +1235,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
|
||||
+27
-24
@@ -2,16 +2,18 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: language table, CI gate, open-PR count; previously: progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
Precise drafting still has approximate ellipse and spline intersections and a few stubbed prompt options
|
||||
(object snap tracking, TRIM/EXTEND/OFFSET options and typed snap overrides have landed); dynamic blocks can't be
|
||||
authored and xrefs can't be edited in place (the Block Editor, REFEDIT, dynamic block editing and XCLIP have landed);
|
||||
plots go only to PDF, PNG and SVG files (plot styles and the Plot dialog have landed); and a DWG saved by
|
||||
CADCraft drops what acadrust doesn't model and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
|
||||
@@ -19,7 +21,7 @@ is ≈ 600–930 h away.
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491 at the 2026-10-10 re-measure, including UI-only commands; not re-measured since; the generated checklist counts engine commands only and now reads 298 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) |
|
||||
| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -80,40 +82,40 @@ Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build:
|
||||
|
||||
## Languages
|
||||
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus partial
|
||||
Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus eighteen catalogs
|
||||
(Ukrainian, Brazilian Portuguese, German, Spanish, Czech, Russian, French, Simplified Chinese, Italian, Korean, Swedish, Arabic, Greek, Hungarian, Turkish, Bosnian, Georgian and Vietnamese). Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Status | UI translated |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | none | 0% |
|
||||
| Spanish | none | 0% |
|
||||
| Simplified Chinese | partial | 1,337 catalog rows |
|
||||
| Spanish | partial | 1,243 catalog rows |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | none | 0% |
|
||||
| Portuguese | none | 0% |
|
||||
| Arabic | partial | 1,229 catalog rows |
|
||||
| French | partial | 1,004 catalog rows |
|
||||
| Portuguese (Brazilian) | partial | 1,243 catalog rows |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
| Greek | partial | 1,330 catalog rows; independent proofreading pending |
|
||||
| German | partial | 1,031 catalog rows |
|
||||
| Korean | partial | 1,235 catalog rows |
|
||||
| Vietnamese | partial | 1,460 catalog rows (2026-11-07); native review pending |
|
||||
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
Other languages shipped: Ukrainian (1,357 rows, the reference catalog, #36), Czech (1,215), Russian (1,005), Italian (1,352), Swedish (1,382), Hungarian (1,326) and Turkish (1,330), counted 2026-10-24; engine messages stay English. Every catalog but Ukrainian's has strings added since it landed that still fall back to English: 6 (Italian, Swedish) to 296 (French) per `cargo xtask i18n` ([localization-parity.md](docs/localization-parity.md)).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h).
|
||||
2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h).
|
||||
1. Gate and land: CI runs `cargo xtask ci` on every PR since #1987 (fmt on main is fixed); land the 450 open PRs (2026-10-24) (15–25 h).
|
||||
2. Stop losing data on save (DXF done, #528; native MULTILEADER, #459, and the save version, #421, done; through DWG, acadrust still drops some content) (30–45 h).
|
||||
3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner).
|
||||
4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input (25–35 h).
|
||||
5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h).
|
||||
6. Autosave, recovery, AUDIT, RECOVER (10–15 h).
|
||||
4. ~~Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input.~~ Done (#379, #441, #60, #519; `snap/tracking.rs`, `pointmod.rs`, `dyninput.rs`).
|
||||
5. ~~Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER.~~ Done (#397, #408, #547); the stubs left are in gaps F1.
|
||||
6. ~~Autosave, recovery, AUDIT, RECOVER.~~ Done on the desktop (#406, #416); the web build has no autosave (gaps F4).
|
||||
7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h): xrefs (#525), groups (#417), the Block Editor (#824, #833, #885), REFEDIT (#818), XCLIP and XCLIPFRAME (#2354) and dynamic block references (#838–#883) landed; left: dynamic-block authoring, editing an xref in place.
|
||||
|
||||
## Progress log
|
||||
|
||||
@@ -136,6 +138,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: language table, CI gate and open-PR count re-checked against main 41d9b5e95 |
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
|
||||
@@ -20,7 +20,9 @@ cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.cadcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -33,4 +35,5 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.cadcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
|
||||
//! own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is CADCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on drops its [`PendingReply`], which cancels a request the app has
|
||||
//! not started, so the app skips it instead of running it late.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use cadcraft_ui_egui::{ControlRequest, PendingReply};
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "cadcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `CadApp::with_control`). An existing `window.cadcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame. Dropping this future (the page's timeout) drops
|
||||
/// `reply`, which cancels the request if the app has not started it.
|
||||
async fn wait(reply: PendingReply) -> Value {
|
||||
loop {
|
||||
if let Some(reply) = reply.try_reply() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -160,7 +162,8 @@ mod web {
|
||||
Box::new(move |cc| {
|
||||
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
|
||||
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
let mut app = CadApp::new(session, services());
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = CadApp::new(session, services()).with_control(control);
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
|
||||
|
||||
@@ -181,6 +181,14 @@ fn services() -> Services {
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
|
||||
let dialog = match dir {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ display_name = "Tasos Sangiotis"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
|
||||
@@ -55,6 +55,8 @@ impl ForeignExtra {
|
||||
pub struct Foreign {
|
||||
/// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types.
|
||||
pub classes: Vec<Vec<RawTag>>,
|
||||
/// Classes retain every source position, including duplicate names.
|
||||
pub complete_class_table: bool,
|
||||
/// Kept objects, in file order.
|
||||
pub objects: Vec<ForeignObject>,
|
||||
/// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object).
|
||||
|
||||
+378
-21
@@ -66,6 +66,8 @@ fn mb(n: u64) -> String {
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod proxy_transport;
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
@@ -107,6 +109,13 @@ mod native {
|
||||
}
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?,
|
||||
);
|
||||
// After application_objects, so the proxy objects it makes are numbered as the DXF's.
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy conversion failed".to_string())?,
|
||||
);
|
||||
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
@@ -390,6 +399,214 @@ mod native {
|
||||
std::panic::catch_unwind(f).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// The version a proxy record of a DWG of `version` names its format by (Open Design
|
||||
/// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004,
|
||||
/// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version.
|
||||
pub(super) fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option<i32> {
|
||||
Some(match version.version_code() {
|
||||
1012 => 19,
|
||||
1014 => 21,
|
||||
1015 => 23,
|
||||
1018 => 25,
|
||||
1021 => 27,
|
||||
1024 => 29,
|
||||
1027 => 31,
|
||||
1032 => 33,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects of another application's class that acadrust keeps only as their DWG record
|
||||
/// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the
|
||||
/// references to them went with them (a dimension's extension dictionary entry naming its
|
||||
/// application data, say), with no note. Each is carried as a proxy object of its class instead
|
||||
/// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it
|
||||
/// again: its common object data (owner, reactors, extension dictionary) and the streams after
|
||||
/// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg
|
||||
/// splits an object record. Objects of the drawing database's own classes are not turned into
|
||||
/// another application's proxies. What is still without a DXF form is named in the notes, so a
|
||||
/// save over the original is not mistaken for a complete one.
|
||||
fn application_objects(doc: &mut acadrust::CadDocument) -> Vec<String> {
|
||||
use acadrust::objects::{ObjectType, ProxyObject};
|
||||
let classes: Vec<acadrust::classes::DxfClass> = doc.classes.iter().cloned().collect();
|
||||
let source = doc.dwg_source_version.unwrap_or(doc.version);
|
||||
if let Some(dwg_version) = proxy_dwg_version(source) {
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
for object in doc.objects.values_mut() {
|
||||
let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } =
|
||||
&*object
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !application_class(&classes, type_name) {
|
||||
continue;
|
||||
}
|
||||
let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version };
|
||||
let handle = *handle;
|
||||
// A record that doesn't split stays as it was, and is named below.
|
||||
let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue };
|
||||
let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue };
|
||||
*object = ObjectType::ProxyObject(ProxyObject {
|
||||
handle,
|
||||
owner: record.owner,
|
||||
reactors: record.reactors,
|
||||
xdictionary_handle: record.xdictionary,
|
||||
proxy_id: 499,
|
||||
class_id: i32::from(class.class_number),
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
payload: record.payload,
|
||||
text_payload: record.text_payload,
|
||||
object_ids: record.object_ids,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for object in doc.objects.values() {
|
||||
if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object {
|
||||
let n = left.entry(type_name.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if left.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total = left.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
let mut list: Vec<String> = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect();
|
||||
if left.len() > NOTE_TYPES {
|
||||
list.push(format!("{} more types", left.len() - NOTE_TYPES));
|
||||
}
|
||||
vec![format!(
|
||||
"DWG: {total} objects of types CADCraft doesn't keep were left out ({}); saving over the original file would lose them",
|
||||
list.join(", ")
|
||||
)]
|
||||
}
|
||||
|
||||
/// Whether `name` is an object class of another application, which only that application
|
||||
/// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose
|
||||
/// leftovers the database regenerates itself.
|
||||
fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool {
|
||||
classes.iter().any(|c| {
|
||||
!c.is_an_entity
|
||||
&& c.dxf_name.eq_ignore_ascii_case(name)
|
||||
&& !c.cpp_class_name.starts_with("AcDb")
|
||||
&& !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p))
|
||||
})
|
||||
}
|
||||
|
||||
/// An application object acadrust kept only as its record: the parts a proxy object carries.
|
||||
struct ObjectRecord {
|
||||
kind: i16,
|
||||
owner: acadrust::types::Handle,
|
||||
reactors: Vec<acadrust::types::Handle>,
|
||||
xdictionary: Option<acadrust::types::Handle>,
|
||||
payload: acadrust::objects::ProxyPayload,
|
||||
text_payload: acadrust::objects::ProxyPayload,
|
||||
object_ids: Vec<acadrust::objects::ProxyObjectReference>,
|
||||
}
|
||||
|
||||
/// Split an object record into its common object data and the uninterpreted streams after it
|
||||
/// (data bits, R2007+ strings and object references), as an application object's proxy holds
|
||||
/// them (Open Design Specification for .dwg, common object data and proxy objects). The record
|
||||
/// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest.
|
||||
fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result<ObjectRecord, String> {
|
||||
use acadrust::io::dwg::{
|
||||
DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader,
|
||||
dwg_stream_writers::bit_writer::DwgBitWriter,
|
||||
};
|
||||
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
if raw.data.len() > 32 << 20 {
|
||||
return Err("object record exceeds the transport limit".into());
|
||||
}
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("object handle stream exceeds its record".into());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?;
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, std::collections::HashMap::new()).map_err(|e| e.to_string())?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?;
|
||||
if kind < 500 {
|
||||
return Err("object has no application class".into());
|
||||
}
|
||||
let common = reader.read_common_non_entity_data(&mut streams, kind);
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
if common.common.handle != handle {
|
||||
return Err("object record names another handle".into());
|
||||
}
|
||||
let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result<ProxyPayload, String> {
|
||||
if count > total_bits {
|
||||
return Err("object stream exceeds its record".into());
|
||||
}
|
||||
let n = u32::try_from(count).map_err(|_| "object stream is too large")?;
|
||||
let mut bytes = vec![0u8; count.div_ceil(8)];
|
||||
for index in 0..count {
|
||||
if bit()
|
||||
&& let Some(b) = bytes.get_mut(index / 8)
|
||||
{
|
||||
*b |= 0x80 >> (index % 8);
|
||||
}
|
||||
}
|
||||
Ok(ProxyPayload::from_bits(&bytes, n))
|
||||
};
|
||||
let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?;
|
||||
let payload = read_bits(main, &mut || streams.read_bit())?;
|
||||
let text_payload = if raw.version >= DxfVersion::AC1021 {
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?;
|
||||
read_bits(bits, &mut || streams.read_text_bit())?
|
||||
} else {
|
||||
ProxyPayload::default()
|
||||
};
|
||||
// Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its
|
||||
// field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+
|
||||
// records give the stream's exact length; the bits of its last byte past it are padding.
|
||||
let mut object_ids = Vec::new();
|
||||
let mut left = if raw.version >= DxfVersion::AC1024 {
|
||||
streams.get_handle_bits().max(0).min(streams.handle_remaining_bits())
|
||||
} else {
|
||||
streams.handle_remaining_bits()
|
||||
};
|
||||
while left >= 8 {
|
||||
if object_ids.len() >= 100_000 {
|
||||
return Err("object has too many references".into());
|
||||
}
|
||||
let before = streams.handle_remaining_bits();
|
||||
let (target, reference) = streams.read_handle_reference(handle);
|
||||
let used = before.saturating_sub(streams.handle_remaining_bits());
|
||||
if used <= 0 || used > left {
|
||||
break;
|
||||
}
|
||||
left -= used;
|
||||
let kind = match reference {
|
||||
DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership,
|
||||
DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership,
|
||||
DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer,
|
||||
DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer,
|
||||
DwgReferenceType::Undefined => ProxyReferenceKind::Undefined,
|
||||
};
|
||||
object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind });
|
||||
}
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
Ok(ObjectRecord {
|
||||
kind,
|
||||
owner: Handle::from(common.owner_handle),
|
||||
reactors: common.reactors.into_iter().map(Handle::from).collect(),
|
||||
xdictionary: common.xdictionary_handle.map(Handle::from),
|
||||
payload,
|
||||
text_payload,
|
||||
object_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
|
||||
/// left unmerged. Returns the cells visited.
|
||||
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
|
||||
@@ -746,7 +963,7 @@ mod native {
|
||||
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
|
||||
/// of the HEADER section: a large input without one costs time, not memory (a line index of
|
||||
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
|
||||
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
|
||||
pub(super) fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
|
||||
let mut offset = 0usize;
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
|
||||
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
|
||||
@@ -806,32 +1023,32 @@ mod native {
|
||||
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
|
||||
/// conversion functions comes here.
|
||||
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
|
||||
let pairs = super::proxy_transport::text_pairs(dxf)?;
|
||||
let mut names = Vec::new();
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
|
||||
let (mut in_classes, mut in_class) = (false, false);
|
||||
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
|
||||
match (code, value) {
|
||||
(b"0", b"ENDSEC") if in_classes => break,
|
||||
(b"2", b"CLASSES") => in_classes = true,
|
||||
(b"0", v) if in_classes => {
|
||||
in_class = v == b"CLASS";
|
||||
// Every record keeps its place, named or not: a proxy numbers its class by
|
||||
// place, so a record without a name must not move the ones after it. An empty
|
||||
// name matches no class, and such a proxy is left out.
|
||||
let (mut section, mut in_classes, mut in_class) = (false, false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
in_class = kind == "CLASS";
|
||||
// Even an unnamed record keeps its place in the source class table.
|
||||
if in_class {
|
||||
names.push(String::new());
|
||||
}
|
||||
}
|
||||
(b"1", v) if in_class => {
|
||||
(1, value) if in_class => {
|
||||
if let Some(last) = names.last_mut() {
|
||||
*last = String::from_utf8_lossy(v).into_owned();
|
||||
*last = value.to_string();
|
||||
}
|
||||
in_class = false;
|
||||
}
|
||||
_ => {}
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
@@ -877,8 +1094,17 @@ mod native {
|
||||
return left_out(objects.len(), entities.len());
|
||||
}
|
||||
};
|
||||
// Name matching cannot choose between different source classes with the same name.
|
||||
// Count those proxies as unwritten rather than silently changing their class identity.
|
||||
let mut unique = std::collections::HashMap::new();
|
||||
for name in classes {
|
||||
unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true);
|
||||
}
|
||||
let number = |id: i32| -> Option<i32> {
|
||||
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
|
||||
if unique.get(&name.to_uppercase()) != Some(&true) {
|
||||
return None;
|
||||
}
|
||||
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
|
||||
};
|
||||
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
|
||||
@@ -971,7 +1197,8 @@ mod native {
|
||||
Some(d) => d,
|
||||
None => dxf.to_vec(),
|
||||
};
|
||||
let classes = dxf_class_names(&data);
|
||||
let (classes, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data))))
|
||||
.map_err(|_| "the DXF proxy class conversion failed".to_string())?;
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
|
||||
.map_err(|e| format!("DXF: {e}"))?;
|
||||
@@ -986,7 +1213,17 @@ mod native {
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
let proxies = proxy_classes(&mut doc, classes.as_deref());
|
||||
// From 2013 the DWG takes the source classes as they are, duplicates and metadata
|
||||
// included; otherwise (before 2013, a binary DXF, classes that can't be written back)
|
||||
// proxies are matched to classes by name or left out.
|
||||
let restored = match source {
|
||||
Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source),
|
||||
_ => None,
|
||||
};
|
||||
let proxies = match restored {
|
||||
Some((objects, entities)) => left_out(objects, entities),
|
||||
None => proxy_classes(&mut doc, classes.as_deref()),
|
||||
};
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
@@ -1159,6 +1396,126 @@ mod native {
|
||||
assert_eq!(guarded(|| 3), Ok(3));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod application_object_tests {
|
||||
use super::*;
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::DxfClass;
|
||||
use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
|
||||
/// An object of another application's class that acadrust keeps only as its record (a
|
||||
/// vertical product's dimension data, say) is carried as that class's proxy object, with its
|
||||
/// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice.
|
||||
#[test]
|
||||
fn undecoded_application_object_is_kept_as_its_proxy() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x2Bu64);
|
||||
let target = Handle::from(0x2Cu64);
|
||||
let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18);
|
||||
// A null pointer between two references: it keeps its place, the one after it is not lost.
|
||||
let references = vec![
|
||||
ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer },
|
||||
];
|
||||
source.objects.insert(
|
||||
handle,
|
||||
ObjectType::RegisteredClass(RegisteredClassObject {
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
dxf_name: "TESTCONTAINER".into(),
|
||||
cpp_class_name: "xyzDbTestContainer".into(),
|
||||
payload: payload.clone(),
|
||||
object_ids: references.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
// acadrust has no model of it: read back, it is only its record.
|
||||
let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle));
|
||||
for generation in 0..2 {
|
||||
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
|
||||
assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}");
|
||||
// The DXF has the object, by its handle, as a proxy record.
|
||||
let text = String::from_utf8_lossy(&dxf);
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]);
|
||||
assert!(record, "{version:?} {generation}: no proxy record for the object");
|
||||
dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") };
|
||||
assert_eq!(proxy.payload.bit_count, payload.bit_count);
|
||||
assert_eq!(proxy.payload.data(), payload.data());
|
||||
assert_eq!(proxy.object_ids, references);
|
||||
let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap();
|
||||
assert_eq!(class.dxf_name, "TESTCONTAINER");
|
||||
assert!(!class.is_an_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The database's own classes are not turned into another application's proxies; what is
|
||||
/// left without a DXF form is said, not dropped silently.
|
||||
#[test]
|
||||
fn database_objects_without_a_form_are_reported() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable"));
|
||||
let handle = Handle::from(0x40u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "BLOCKPROPERTIESTABLE".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0; 8]),
|
||||
raw_dwg_handle_bits: 0,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"DWG: 1 objects of types CADCraft doesn't keep were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A record that doesn't split stays as it was and is named, never guessed at.
|
||||
#[test]
|
||||
fn invalid_object_records_are_rejected() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(object_record(&raw, 42).is_err());
|
||||
}
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x41u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "TESTCONTAINER".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0xFF; 4]),
|
||||
raw_dwg_handle_bits: 1000,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
@@ -1646,7 +2003,7 @@ mod tests {
|
||||
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
|
||||
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
|
||||
/// from DXF is written whole, with the byte AutoCAD writes (0).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
|
||||
use acadrust::objects::*;
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
|
||||
//! registered entities acadrust has no model for become proxy entities of their class; on writing
|
||||
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::{DxfClass, ProxyFlags};
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
|
||||
use acadrust::objects::{ObjectType, ProxyPayload};
|
||||
|
||||
fn class_number(index: usize) -> Result<i16, String> {
|
||||
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
|
||||
}
|
||||
|
||||
/// After a DWG read: each registered entity of another application's class that acadrust keeps
|
||||
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
|
||||
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
|
||||
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
|
||||
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
|
||||
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
|
||||
/// read, also named, as its opaque data has no DXF form beside them.
|
||||
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
|
||||
let classes: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
let mut numbers = HashMap::new();
|
||||
let mut identities = HashMap::new();
|
||||
for (index, class) in classes.iter().enumerate() {
|
||||
let number = class_number(index).ok();
|
||||
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
|
||||
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
|
||||
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
|
||||
}
|
||||
let identity = |dxf: &str, cpp: &str, entity: bool| {
|
||||
identities
|
||||
.get(&(dxf, cpp, entity))
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(i32::from)
|
||||
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
|
||||
};
|
||||
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
|
||||
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
|
||||
let handles: Vec<_> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
|
||||
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut notes = Vec::new();
|
||||
let mut entity_count = 0usize;
|
||||
let mut object_count = 0usize;
|
||||
let mut semantic = 0usize;
|
||||
for handle in handles {
|
||||
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
|
||||
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
|
||||
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
|
||||
let data = match &entity.data {
|
||||
ExtendedEntityData::Proxy(data) => {
|
||||
let mut proxy = data.clone();
|
||||
proxy.class_id = remap(proxy.class_id)?;
|
||||
ExtendedEntityData::Proxy(proxy)
|
||||
}
|
||||
ExtendedEntityData::RegisteredClass(data) => {
|
||||
if !data.properties.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
|
||||
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
|
||||
ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: String::new(),
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
|
||||
payload: data.payload.clone(),
|
||||
text_payload,
|
||||
object_ids: data.object_ids.clone(),
|
||||
})
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(data)))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("application record conversion failed".into()));
|
||||
match converted {
|
||||
Ok(Some(Some(data))) => {
|
||||
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
|
||||
entity.data = data;
|
||||
}
|
||||
}
|
||||
Ok(Some(None)) => semantic = semantic.saturating_add(1),
|
||||
Ok(None) => {}
|
||||
Err(why) => {
|
||||
doc.remove_entity(handle);
|
||||
entity_count = entity_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "entity", handle, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.objects.retain(|handle, object| {
|
||||
if let ObjectType::ProxyObject(proxy) = object {
|
||||
match remap(proxy.class_id) {
|
||||
Ok(id) => proxy.class_id = id,
|
||||
Err(why) => {
|
||||
object_count = object_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "object", *handle, &why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if entity_count > 0 || object_count > 0 {
|
||||
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
|
||||
}
|
||||
if semantic > 0 {
|
||||
notes.push(format!(
|
||||
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
|
||||
if notes.len() < 6 {
|
||||
let why: String = why.chars().take(256).collect();
|
||||
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder's registered-class representation omits the separate R2007+ string stream.
|
||||
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
|
||||
use acadrust::io::dwg::DwgVersion;
|
||||
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
|
||||
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
if raw.version < DxfVersion::AC1021 {
|
||||
return Ok(ProxyPayload::default());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("DWG proxy text: handle stream exceeds record".into());
|
||||
}
|
||||
// Frame the verbatim record for the dependency's checked object reader.
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
reader.read_common_entity_data(&mut streams, kind);
|
||||
if kind == 498 {
|
||||
// An existing registered-class envelope is a proxy and carries its class name first.
|
||||
streams.read_variable_text();
|
||||
} else if kind < 500 {
|
||||
return Err("DWG proxy text: registered entity has no application class".into());
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
|
||||
if bits > total_bits {
|
||||
return Err("DWG proxy text: string stream exceeds record".into());
|
||||
}
|
||||
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
|
||||
let mut bytes = vec![0; bits.div_ceil(8)];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
|
||||
if streams.read_text_bit() {
|
||||
*byte |= 0x80 >> bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
Ok(ProxyPayload::from_bits(&bytes, count))
|
||||
}
|
||||
|
||||
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
|
||||
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
|
||||
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
|
||||
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
|
||||
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let version =
|
||||
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
|
||||
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
|
||||
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
|
||||
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
|
||||
Some(std::iter::from_fn(move || {
|
||||
let code = lines.next()?;
|
||||
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
|
||||
let pair = || {
|
||||
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
|
||||
let text = if let Some(encoding) = encoding {
|
||||
encoding.decode(value).0.into_owned()
|
||||
} else if unicode {
|
||||
String::from_utf8_lossy(value).into_owned()
|
||||
} else {
|
||||
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
|
||||
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
|
||||
};
|
||||
let text = decode_legacy_escapes(&text);
|
||||
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
|
||||
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
|
||||
Some((code, text))
|
||||
};
|
||||
Some(pair())
|
||||
}))
|
||||
}
|
||||
|
||||
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
|
||||
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
|
||||
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
|
||||
/// flags or instance count out of range, more classes than the numbers allow. Then
|
||||
/// `proxy_classes` matches the classes by name instead.
|
||||
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
|
||||
let pairs = text_pairs(dxf)?;
|
||||
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
|
||||
let (mut section, mut in_classes) = (false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
if kind != "CLASS" {
|
||||
return None;
|
||||
}
|
||||
records.push(Vec::new());
|
||||
}
|
||||
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
|
||||
}
|
||||
|
||||
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
|
||||
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
|
||||
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
|
||||
let mut class = DxfClass::new(text(1), text(2));
|
||||
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
class.application_name = text(3);
|
||||
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
|
||||
class.instance_count = i32::try_from(integer(91)?).ok()?;
|
||||
class.was_zombie = integer(280)? != 0;
|
||||
class.is_an_entity = integer(281)? != 0;
|
||||
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
|
||||
class.class_number = number;
|
||||
Some(class)
|
||||
}
|
||||
|
||||
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
|
||||
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
|
||||
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
|
||||
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
|
||||
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
|
||||
/// changed, when the classes would be more than the class numbers allow.
|
||||
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
|
||||
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
|
||||
let mut any = false;
|
||||
let mut entities = Vec::new();
|
||||
for entity in doc.entities() {
|
||||
if let EntityType::Extended(e) = entity
|
||||
&& let ExtendedEntityData::Proxy(p) = &e.data
|
||||
{
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
entities.push(e.common.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut objects = Vec::new();
|
||||
for (handle, object) in &doc.objects {
|
||||
if let ObjectType::ProxyObject(p) = object {
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
objects.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Some((0, 0));
|
||||
}
|
||||
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
|
||||
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
|
||||
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
|
||||
class_number(last).ok()?;
|
||||
}
|
||||
for handle in &entities {
|
||||
doc.remove_entity(*handle);
|
||||
}
|
||||
for handle in &objects {
|
||||
doc.objects.remove(handle);
|
||||
}
|
||||
doc.classes.clear();
|
||||
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
|
||||
class.class_number = class_number(index).ok()?;
|
||||
doc.classes.push_preserving(class);
|
||||
}
|
||||
Some((objects.len(), entities.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
fn proxy_doc() -> CadDocument {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.version = DxfVersion::AC1032;
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id: 501,
|
||||
dxf_subclass: String::new(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
text_payload: ProxyPayload::default(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
|
||||
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
|
||||
let bytes = dxf(&[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$DIMSTYLE"),
|
||||
(2, "CLASSES"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "CLASSES"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "First"),
|
||||
(3, "Первый"),
|
||||
(90, "1025"),
|
||||
(91, "7"),
|
||||
(280, "1"),
|
||||
(281, "1"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "Second"),
|
||||
(3, "Второй"),
|
||||
(90, "128"),
|
||||
(281, "1"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
]);
|
||||
let classes = source_classes(&bytes).unwrap();
|
||||
assert_eq!(classes.len(), 2);
|
||||
assert_eq!(classes[0].application_name, "Первый");
|
||||
assert_eq!(classes[0].proxy_flags.0, 1025);
|
||||
assert_eq!(classes[0].instance_count, 7);
|
||||
assert!(classes[0].was_zombie);
|
||||
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
|
||||
assert_eq!(classes[1].cpp_class_name, "Second");
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
|
||||
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
|
||||
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
|
||||
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
|
||||
let mut class = vec![(1, "A"), (2, "A")];
|
||||
class.retain(|(c, _)| *c != code);
|
||||
class.push((code, value));
|
||||
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
|
||||
groups.extend(class);
|
||||
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
|
||||
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
|
||||
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
|
||||
let classes = source_classes(text.as_bytes()).unwrap();
|
||||
assert_eq!(classes[0].dxf_name, "Объект");
|
||||
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
|
||||
|
||||
let prefix =
|
||||
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
|
||||
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
|
||||
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
|
||||
// R2007+ text is UTF-8 even with a legacy code-page declaration.
|
||||
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_of_missing_classes_are_left_out() {
|
||||
for id in [-1, 499, 501, i32::MAX] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = id;
|
||||
}
|
||||
let mut class = DxfClass::new_entity("ENTITY", "Entity");
|
||||
class.class_number = 500;
|
||||
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
}
|
||||
// Class 501 is there: the proxy keeps it, in its place.
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
|
||||
// Without proxies the classes stay the decoder's.
|
||||
let mut doc = CadDocument::new();
|
||||
let before: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
|
||||
use acadrust::entities::{Line, RawRecord};
|
||||
use acadrust::objects::ProxyObject;
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
for count in [1, 10] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = i32::MAX;
|
||||
}
|
||||
let template = doc.entities().next().unwrap().clone();
|
||||
for _ in 1..count {
|
||||
let mut entity = template.clone();
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
doc.add_entity(entity).unwrap();
|
||||
}
|
||||
let object = Handle::new(u64::MAX - 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
|
||||
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
|
||||
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
let notes = prepare(&mut doc);
|
||||
assert_eq!(notes.len(), (count + 1).min(6) + 1);
|
||||
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
|
||||
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
|
||||
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
|
||||
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
|
||||
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn opaque_registered_class_ambiguity_is_not_guessed() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM".into(),
|
||||
cpp_class_name: "Custom".into(),
|
||||
properties: vec![],
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
assert!(class_number(usize::MAX).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
|
||||
use acadrust::entities::RawRecord;
|
||||
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
|
||||
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
|
||||
let mut doc = CadDocument::new();
|
||||
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
let handle = doc
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SEMANTIC".into(),
|
||||
cpp_class_name: "Semantic".into(),
|
||||
properties: vec![SemanticProperty {
|
||||
subclass: "Semantic".into(),
|
||||
code: 1,
|
||||
value: SemanticPropertyValue::Text("value".into()),
|
||||
}],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
if payload.bit_count == 0 {
|
||||
assert!(prepare(&mut doc).is_empty());
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
} else {
|
||||
// Kept as read, and named: its opaque data has no DXF form beside the properties.
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod string_stream_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
|
||||
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
|
||||
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
|
||||
let payload = ProxyPayload::from_bits(&[0x80], 1);
|
||||
source
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: "CustomText".into(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: payload.clone(),
|
||||
text_payload: text.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let entity = decoded.entities().next().unwrap();
|
||||
let handle = entity.common().handle;
|
||||
let raw = entity.common().raw_record.clone().unwrap();
|
||||
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
|
||||
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
|
||||
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
|
||||
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM_TEXT".into(),
|
||||
cpp_class_name: "CustomText".into(),
|
||||
properties: vec![],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
});
|
||||
entity.common.raw_record = Some(raw);
|
||||
assert!(prepare(&mut decoded).is_empty());
|
||||
for _ in 0..2 {
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
|
||||
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let p = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.payload.bit_count, 1);
|
||||
assert_eq!(p.payload.data(), payload.data());
|
||||
assert_eq!(p.text_payload.bit_count, 19);
|
||||
assert_eq!(p.text_payload.data(), text.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_string_stream_boundaries_return_errors() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(registered_text(&raw).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
|
||||
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
|
||||
/// double-byte code page and `XXXX` is the character's two bytes in hex.
|
||||
fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("-xref", "External References", run_dash_xref)
|
||||
.alias(&["-xr"])
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
|
||||
CommandSpec::new("xbind", "Bind...", run_xbind)
|
||||
.menu(&["Modify", "Object", "External Reference", "Bind..."])
|
||||
@@ -201,38 +201,6 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The xrefs a JSON call names (`names`, default all); an error when none match.
|
||||
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
|
||||
if p.get("exact").and_then(Value::as_bool) == Some(true) {
|
||||
let lits: Vec<&str> = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
|
||||
Some(Value::String(t)) => vec![t.as_str()],
|
||||
_ => str_param(p, "name").into_iter().collect(),
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let mut names = Vec::new();
|
||||
let mut selected = std::collections::HashSet::new();
|
||||
for literal in &lits {
|
||||
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
|
||||
if selected.insert(block.name.to_lowercase()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let normalized = literal.to_lowercase();
|
||||
let mut matches = xrefs(d).filter(|block| block.name.to_lowercase() == normalized);
|
||||
if let Some(block) = matches.next() {
|
||||
if matches.next().is_some() {
|
||||
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
|
||||
}
|
||||
if selected.insert(block.name.to_lowercase()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
|
||||
}
|
||||
return Ok(names);
|
||||
}
|
||||
let pattern = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
|
||||
Some(Value::String(t)) => t.clone(),
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_co
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
|
||||
//! `;` comments): ALIASEDIT and REINIT.
|
||||
//!
|
||||
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
|
||||
//! every drawing; command names themselves always win. Lines that define external commands
|
||||
//! A loaded file replaces the user aliases, which add to or override the built-in ones for every
|
||||
//! drawing; command names themselves always win. Lines that define external commands
|
||||
//! (programs the file would have the application start) are never run: they are skipped with a
|
||||
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
|
||||
//! defaults.
|
||||
//!
|
||||
//! The aliases are user settings, never part of a drawing: [`alias_profile`] gives them (and the
|
||||
//! file REINIT reads) to the host's user profile, and [`restore_aliases`] brings them back on the
|
||||
//! next start. The desktop and web hosts keep that profile; the headless CLI has no profile, so
|
||||
//! aliases set in a script last for that session only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
@@ -20,10 +25,21 @@ use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
/// Longest alias name.
|
||||
const MAX_ALIAS_CHARS: usize = 64;
|
||||
/// Longest command id (the catalog's longest is `texttofront.dimensions`; a test keeps it true).
|
||||
const MAX_COMMAND_ID_CHARS: usize = 32;
|
||||
/// Longest line read.
|
||||
const MAX_LINE_CHARS: usize = 4096;
|
||||
/// Most warnings reported.
|
||||
const MAX_WARNINGS: usize = 100;
|
||||
/// Longest alias file path kept in the user profile.
|
||||
const MAX_ALIAS_FILE_CHARS: usize = 1024;
|
||||
|
||||
/// The largest alias section [`alias_profile`] can produce: [`MAX_ALIASES`] entries, each a
|
||||
/// [`MAX_ALIAS_CHARS`]-character name (up to four UTF-8 bytes per character, and JSON adds its
|
||||
/// quotes) mapped to a command id, plus the alias file path and the section's keys. A host that
|
||||
/// keeps the section in its user profile allows at least this much, so a full set always comes
|
||||
/// back after a restart.
|
||||
pub const MAX_ALIAS_PROFILE_BYTES: usize = MAX_ALIASES * (MAX_ALIAS_CHARS * 4 + MAX_COMMAND_ID_CHARS + 6) + MAX_ALIAS_FILE_CHARS * 4 + 64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aliases {
|
||||
@@ -43,6 +59,49 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
|
||||
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
|
||||
pub fn alias_profile() -> Value {
|
||||
let st = store();
|
||||
// A path [`restore_aliases`] would refuse is left out, so it can't grow the profile either.
|
||||
json!({ "aliases": st.map, "file": st.file.as_deref().filter(|f| keepable_path(f)) })
|
||||
}
|
||||
|
||||
/// Whether the profile keeps an alias file path: short enough, and no control characters.
|
||||
fn keepable_path(f: &str) -> bool {
|
||||
f.chars().count() <= MAX_ALIAS_FILE_CHARS && !f.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Restore aliases saved by [`alias_profile`], replacing the user aliases and the file.
|
||||
///
|
||||
/// The saved set is untrusted (a hand-edited or older profile): entries that are not aliases of
|
||||
/// real commands are skipped, command names are never aliased, at most [`MAX_ALIASES`] are kept,
|
||||
/// and a malformed section changes nothing.
|
||||
pub fn restore_aliases(saved: &Value) {
|
||||
let Some(obj) = saved.as_object() else { return };
|
||||
let file = match obj.get("file") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if keepable_path(f) => Some(f.clone()),
|
||||
Some(Value::String(_)) => None,
|
||||
// A file entry that is not a path makes the section malformed: keep the aliases as they are.
|
||||
Some(_) => return,
|
||||
};
|
||||
let Some(map) = obj.get("aliases").and_then(Value::as_object) else { return };
|
||||
let mut aliases = BTreeMap::new();
|
||||
// Entries that are skipped don't use up the cap.
|
||||
for (alias, target) in map {
|
||||
if aliases.len() >= MAX_ALIASES {
|
||||
break;
|
||||
}
|
||||
let Ok(name) = alias_name(alias) else { continue };
|
||||
let Some(id) = target.as_str().and_then(command_id) else { continue };
|
||||
aliases.insert(name, id);
|
||||
}
|
||||
let mut st = store();
|
||||
st.map = aliases;
|
||||
st.file = file;
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
pub fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
@@ -201,3 +260,25 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_COMMAND_ID_CHARS;
|
||||
|
||||
/// [`super::MAX_ALIAS_PROFILE_BYTES`] bounds a saved alias's command id by the longest id in
|
||||
/// the catalog: keep that true as commands are added.
|
||||
#[test]
|
||||
fn every_command_id_fits_the_saved_alias_bound() {
|
||||
for spec in crate::cmd::command_specs() {
|
||||
assert!(spec.id.chars().count() <= MAX_COMMAND_ID_CHARS, "`{}` exceeds the alias profile bound", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// An alias file path the profile couldn't restore isn't saved in it.
|
||||
#[test]
|
||||
fn only_paths_the_profile_restores_are_saved() {
|
||||
assert!(super::keepable_path("C:\\support\\mine.pgp"));
|
||||
assert!(!super::keepable_path(&"x".repeat(super::MAX_ALIAS_FILE_CHARS + 1)));
|
||||
assert!(!super::keepable_path("mine\n.pgp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,31 +26,11 @@ const MAX_PURGE_PASSES: usize = 64;
|
||||
|
||||
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
|
||||
// beside the drawing, so those become `_`.
|
||||
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
|
||||
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
|
||||
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
|
||||
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
|
||||
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file),
|
||||
None => std::path::PathBuf::from(file),
|
||||
};
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().into_owned();
|
||||
let file = format!("{layout}.dwt");
|
||||
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||
None => file,
|
||||
}
|
||||
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
|
||||
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
|
||||
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
|
||||
for suffix in 2u64.. {
|
||||
let alternative = parent.join(format!("{stem}-{suffix}.{extension}"));
|
||||
if !alternative.exists() {
|
||||
return alternative.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
unreachable!("the default layout template filename suffix space is exhausted")
|
||||
}
|
||||
|
||||
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||
@@ -153,32 +133,3 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod default_path_tests {
|
||||
use super::*;
|
||||
|
||||
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
|
||||
#[test]
|
||||
fn layout_name_is_one_file_name() {
|
||||
let s = Session::empty();
|
||||
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
|
||||
let p = default_path(&s, name);
|
||||
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
|
||||
assert!(p.ends_with(".dwt"));
|
||||
}
|
||||
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
|
||||
}
|
||||
|
||||
/// Windows device names are prefixed so the suggestion is never a device.
|
||||
#[test]
|
||||
fn reserved_device_names_are_prefixed() {
|
||||
let s = Session::empty();
|
||||
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
|
||||
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
|
||||
}
|
||||
for name in ["COM0", "CONSOLE", "COM10"] {
|
||||
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ fn text_of(k: &EntityKind) -> Option<String> {
|
||||
EntityKind::Text(t) => Some(t.value.clone()),
|
||||
EntityKind::AttDef(a) => Some(a.text.value.clone()),
|
||||
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
|
||||
// A multileader's text (one with a block instead has none, #1081).
|
||||
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
|
||||
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
|
||||
_ => None,
|
||||
}
|
||||
@@ -25,11 +27,16 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
|
||||
EntityKind::Text(t) => t.value = text.to_string(),
|
||||
EntityKind::AttDef(a) => a.text.value = text.to_string(),
|
||||
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
|
||||
EntityKind::MLeader(m) if m.text.is_some() => {
|
||||
if let Some(t) = &mut m.text {
|
||||
t.contents = text.replace('\n', "\\P");
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
d.text = if text == "<>" { String::new() } else { text.to_string() };
|
||||
d.block = None;
|
||||
}
|
||||
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
|
||||
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
|
||||
}
|
||||
set_kind(s, h, k)
|
||||
}
|
||||
@@ -53,12 +60,18 @@ pub(super) struct TextEditM {
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
/// The MTEXT being edited, for the in-place editor (#842).
|
||||
mtext: Option<cadcraft_doc::MText>,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.h.as_ref().and(self.mtext.clone())
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
@@ -83,7 +96,24 @@ impl Interactive for TextEditM {
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
|
||||
// Refused when picked, not after the new text has been typed.
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let kind = curves::entity(s, h)?.kind;
|
||||
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
|
||||
self.mtext = match kind {
|
||||
EntityKind::MText(m) => Some(m),
|
||||
// A multileader's text opens in the same editor (#1081).
|
||||
EntityKind::MLeader(m) => m.text,
|
||||
_ => None,
|
||||
};
|
||||
// The editor would drop part of a text this long, and saving would lose it: the
|
||||
// command line asks for it instead.
|
||||
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
|
||||
self.mtext = None;
|
||||
s.echo("This text is too long for the editor; enter the new text at the command line.");
|
||||
}
|
||||
self.h = Some((h, old));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -93,10 +123,12 @@ impl Interactive for TextEditM {
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
|
||||
@@ -402,6 +402,17 @@ fn apply_kind_properties(
|
||||
t.rotation = x.to_radians();
|
||||
}
|
||||
}
|
||||
// A multileader's text: its contents (#1081) and height.
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
if let Some(v) = str_param(params, "text") {
|
||||
t.contents = v.replace('\n', "\\P");
|
||||
}
|
||||
if let Some(x) = num("height").filter(|x| *x > 0.0) {
|
||||
t.height = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
|
||||
pl.const_width = x;
|
||||
|
||||
@@ -108,9 +108,8 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
|
||||
|
||||
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
|
||||
pub fn wildcard(pat: &str, text: &str) -> bool {
|
||||
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
|
||||
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
|
||||
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
|
||||
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
|
||||
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
|
||||
// Iterative matcher with single back-track point (linear-ish, no recursion).
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
|
||||
@@ -27,10 +27,7 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
|
||||
if !writing {
|
||||
match read_text_file(&path) {
|
||||
Some(t) => {
|
||||
// Each line has a String header and a separately allocated buffer. Charge a
|
||||
// conservative 64 bytes per line for both, including the allocator's minimum
|
||||
// allocation size, before collecting so many short lines cannot evade the budget.
|
||||
charge_bytes(t.len().saturating_add(t.lines().count().saturating_mul(64)))?;
|
||||
charge_bytes(t.len())?;
|
||||
f.lines = t.lines().map(str::to_string).collect();
|
||||
}
|
||||
None => return Ok(Flow::Value(Value::Nil)),
|
||||
@@ -103,19 +100,8 @@ fn can_write(_path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_text_file(path: &str, text: &str, append: bool) {
|
||||
use std::io::Write;
|
||||
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
|
||||
// instead of stalling the interpreter; only a regular file is then truncated and written.
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.create(true).write(true).append(append);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
if let Ok(mut f) = o.open(path)
|
||||
&& f.metadata().is_ok_and(|m| m.is_file())
|
||||
&& (append || f.set_len(0).is_ok())
|
||||
{
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
|
||||
if let Ok(mut f) = f {
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -132,29 +118,3 @@ fn file_exists(path: &str) -> bool {
|
||||
fn file_exists(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
|
||||
#[test]
|
||||
fn write_to_a_fifo_without_a_reader_does_not_block() {
|
||||
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
write_text_file(fifo.to_str().unwrap(), "x", false);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
let f = dir.join("f.txt");
|
||||
std::fs::write(&f, "long old text").unwrap();
|
||||
write_text_file(f.to_str().unwrap(), "new", false);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
|
||||
write_text_file(f.to_str().unwrap(), "+", true);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
+112
-11
@@ -33,6 +33,9 @@ pub struct CharFmt {
|
||||
pub font: Option<String>,
|
||||
/// Absolute character height set inline (`\H`); `None`: the text's.
|
||||
pub height: Option<f64>,
|
||||
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
|
||||
/// back as it was so the part keeps scaling with the text's height.
|
||||
pub height_rel: Option<f64>,
|
||||
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
|
||||
pub color: Option<MTextColor>,
|
||||
}
|
||||
@@ -99,26 +102,60 @@ fn arg_end(chars: &[char], from: usize) -> usize {
|
||||
impl Formatted {
|
||||
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
|
||||
pub fn from_contents(contents: &str) -> Formatted {
|
||||
Self::from_contents_with_height(contents, None)
|
||||
}
|
||||
|
||||
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
|
||||
/// inline height before it scales that one instead of being dropped.
|
||||
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
|
||||
Self::parse(contents, height).0
|
||||
}
|
||||
|
||||
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters, [`MAX_PARAS`]
|
||||
/// paragraphs or [`MAX_ATOMS`] kept codes the rest would be dropped, and saving would lose it.
|
||||
pub fn fits(contents: &str) -> bool {
|
||||
Self::parse(contents, None).1
|
||||
}
|
||||
|
||||
/// Whether `edited`, what the editor gives back for `original` (a text `height` high), is the
|
||||
/// text as it was: the same, or what opening it and pressing OK without an edit gives (a
|
||||
/// non-canonical `{\fArial|b1;first}` comes back written another way).
|
||||
pub fn leaves_unchanged(original: &str, height: Option<f64>, edited: &str) -> bool {
|
||||
edited == original || edited == Self::from_contents_with_height(original, height).to_contents()
|
||||
}
|
||||
|
||||
/// The parse, and whether it took in all of `contents`.
|
||||
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
|
||||
let mut f = Formatted::default();
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut stack: Vec<CharFmt> = Vec::new();
|
||||
let mut cur = CharFmt::default();
|
||||
let mut props = ParaProps::default();
|
||||
f.paras = vec![props.clone()];
|
||||
// Cleared when a paragraph, a kept code or a nesting level doesn't fit.
|
||||
let mut whole = true;
|
||||
// Groups opened past the 64 levels kept: their closing braces pop nothing.
|
||||
let mut unkept = 0usize;
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if f.fmt.len() >= MAX_CHARS {
|
||||
break;
|
||||
// Braces after the last character the editor holds add no text.
|
||||
if f.fmt.len() >= MAX_CHARS && !matches!(c, '{' | '}' | '\r') {
|
||||
return (f, false);
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
if stack.len() < 64 {
|
||||
stack.push(cur.clone());
|
||||
} else {
|
||||
unkept = unkept.saturating_add(1);
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
if let Some(p) = stack.pop() {
|
||||
if unkept > 0 {
|
||||
unkept -= 1;
|
||||
} else if let Some(p) = stack.pop() {
|
||||
cur = p;
|
||||
}
|
||||
i += 1;
|
||||
@@ -128,6 +165,8 @@ impl Formatted {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
} else {
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -140,6 +179,7 @@ impl Formatted {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
Some('P') => whole = false,
|
||||
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
|
||||
Some('L') => cur.underline = true,
|
||||
Some('l') => cur.underline = false,
|
||||
@@ -151,7 +191,7 @@ impl Formatted {
|
||||
i = end + 1;
|
||||
match k {
|
||||
'f' | 'F' => apply_font(&mut cur, &a),
|
||||
'H' => cur.height = parse_height(&a, cur.height),
|
||||
'H' => apply_height(&mut cur, &a, height),
|
||||
'C' => {
|
||||
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
|
||||
}
|
||||
@@ -177,6 +217,7 @@ impl Formatted {
|
||||
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
|
||||
i = end + 1;
|
||||
}
|
||||
whole &= f.atoms.len() < MAX_ATOMS;
|
||||
f.push_atom(code, &cur);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +228,7 @@ impl Formatted {
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
(f, whole)
|
||||
}
|
||||
|
||||
fn push_char(&mut self, c: char, fmt: &CharFmt) {
|
||||
@@ -445,7 +486,9 @@ impl Formatted {
|
||||
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
|
||||
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
|
||||
}
|
||||
if let Some(h) = f.height {
|
||||
if let Some(m) = f.height_rel {
|
||||
s.push_str(&format!("\\H{}x;", trim(m)));
|
||||
} else if let Some(h) = f.height {
|
||||
s.push_str(&format!("\\H{};", trim(h)));
|
||||
}
|
||||
match f.color {
|
||||
@@ -462,12 +505,32 @@ fn trim(v: f64) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
|
||||
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
|
||||
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
|
||||
/// becomes absolute.
|
||||
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
|
||||
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
|
||||
let a = a.trim();
|
||||
match a.strip_suffix(['x', 'X']) {
|
||||
// A relative height needs the current one; without it the code is dropped.
|
||||
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
|
||||
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
|
||||
let Some(r) = a.strip_suffix(['x', 'X']) else {
|
||||
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
|
||||
cur.height = Some(h);
|
||||
cur.height_rel = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
|
||||
match (cur.height, cur.height_rel) {
|
||||
(_, Some(r0)) => {
|
||||
if let Some(m) = valid(r0 * m) {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +556,22 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relative_height_is_written_back_relative() {
|
||||
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
|
||||
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
|
||||
let out = f.to_contents();
|
||||
assert!(out.contains("\\H2x;big"), "{out}");
|
||||
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
|
||||
// Opened again for a taller text, the part scales with it.
|
||||
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
|
||||
// Relative over relative multiplies; over an absolute one it is absolute.
|
||||
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
|
||||
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
|
||||
// With no height known it is still kept.
|
||||
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_formats_and_atoms() {
|
||||
for src in [
|
||||
@@ -563,4 +642,26 @@ mod tests {
|
||||
let _ = f.fmt_at(usize::MAX);
|
||||
let _ = Formatted::from_contents(&"{".repeat(100_000));
|
||||
}
|
||||
|
||||
/// A text with more paragraphs or kept codes than the editor holds doesn't fit, so TEXTEDIT
|
||||
/// leaves it to the command line instead of dropping the rest on save.
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_codes_do_not_fit() {
|
||||
assert!(Formatted::fits("one\\Ptwo\\A1;three"));
|
||||
assert!(!Formatted::fits(&"a\\P".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"a\n".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"\\A1;".repeat(MAX_ATOMS + 1)));
|
||||
assert!(Formatted::fits(&"\\A1;".repeat(MAX_ATOMS)));
|
||||
// A full formatted run's closing brace adds no text: it still fits.
|
||||
assert!(Formatted::fits(&format!("{{\\L{}}}", "x".repeat(MAX_CHARS))));
|
||||
assert!(!Formatted::fits(&format!("{{\\L{}}}y", "x".repeat(MAX_CHARS))));
|
||||
// OK without an edit leaves a text alone, also one the editor writes another way.
|
||||
let odd = "{\\fArial|b1;first}";
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), &Formatted::from_contents_with_height(odd, Some(2.5)).to_contents()));
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), odd));
|
||||
assert!(!Formatted::leaves_unchanged(odd, Some(2.5), "second"));
|
||||
// Nesting past the 64 levels the editor keeps doesn't fit.
|
||||
assert!(Formatted::fits(&format!("{}a{}", "{".repeat(64), "}".repeat(64))));
|
||||
assert!(!Formatted::fits(&format!("{}a{}", "{".repeat(65), "}".repeat(65))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +286,9 @@ pub trait Interactive: Send {
|
||||
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
|
||||
None
|
||||
}
|
||||
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
|
||||
/// the UI places its in-place editor and with which height, width and justification.
|
||||
/// The multiline text being typed (MTEXT, once its box is known, with empty contents) or edited
|
||||
/// (TEXTEDIT on an MTEXT or a multileader's text, with its contents): where the UI places its
|
||||
/// in-place editor and with which height, width, justification and starting text.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -75,6 +75,57 @@ fn reinit_reads_the_alias_file_again() {
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_alias_profile_restores_without_overriding_commands() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
s.execute("aliasedit", &json!({ "set": { "zzp": "qsave", "zzcmd": "line" } })).unwrap();
|
||||
let saved = cadcraft_engine::cmd::file::alias_profile();
|
||||
assert_eq!(saved["aliases"]["zzp"], json!("qsave"));
|
||||
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zzp"), "zzp");
|
||||
cadcraft_engine::cmd::file::restore_aliases(&saved);
|
||||
assert_eq!(resolve_alias("zzp"), "qsave");
|
||||
assert_eq!(resolve_alias("zzcmd"), "line");
|
||||
assert_eq!(resolve_alias("line"), "line", "command names win");
|
||||
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
|
||||
|
||||
// Hostile saved entries are skipped, and an oversized set is capped without panicking.
|
||||
let mut hostile = serde_json::Map::new();
|
||||
for i in 0..12_000 {
|
||||
hostile.insert(format!("ZZ{i}"), json!("line"));
|
||||
}
|
||||
hostile.insert("LINE".into(), json!("circle"));
|
||||
hostile.insert("ZZLONG".into(), json!("x".repeat(5000)));
|
||||
hostile.insert("ZZNUM".into(), json!(3));
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": hostile, "file": "mine.pgp" }));
|
||||
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
|
||||
assert_eq!(resolve_alias("zzlong"), "zzlong");
|
||||
assert_eq!(resolve_alias("zzn"), "zzn");
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
let restored = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
|
||||
assert!(restored <= cadcraft_engine::cmd::file::MAX_ALIASES, "the restored set is capped, not {restored}");
|
||||
assert!(restored > 9_000, "the valid entries restore, not just {restored}");
|
||||
assert_eq!(listing["file"], json!("mine.pgp"));
|
||||
|
||||
// Skipped entries don't use up the cap: invalid ones sorted first leave a full valid set.
|
||||
let mut first_bad = serde_json::Map::new();
|
||||
for i in 0..cadcraft_engine::cmd::file::MAX_ALIASES {
|
||||
first_bad.insert(format!("A{i:05}"), json!("nosuchcommand"));
|
||||
first_bad.insert(format!("B{i:05}"), json!("line"));
|
||||
}
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": first_bad, "file": null }));
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
assert_eq!(listing["aliases"].as_object().map_or(0, serde_json::Map::len), cadcraft_engine::cmd::file::MAX_ALIASES);
|
||||
// A malformed section is skipped without changing what ALIASEDIT holds.
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": { "zzchange": "circle" }, "file": 7 }));
|
||||
assert_eq!(resolve_alias("zzchange"), "zzchange", "a malformed section restores nothing");
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zz0"), "zz0", "clear still empties the restored set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_alias_files_never_panic() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -86,6 +86,7 @@ mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
mod mleader_text_edit;
|
||||
mod mline;
|
||||
mod mocoro;
|
||||
mod modify_curves;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
|
||||
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
|
||||
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_of(s: &Session) -> String {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_and_properties_change_a_multileaders_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
|
||||
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
|
||||
// the multileader's MTEXT.
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "edited\\Psecond line");
|
||||
s.cancel();
|
||||
|
||||
// The Properties palette's Contents field.
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!(text_of(&s), "from properties");
|
||||
}
|
||||
|
||||
/// An edited multileader text is undone in one step and is what a DXF save keeps.
|
||||
#[test]
|
||||
fn an_edited_multileader_text_undoes_and_survives_a_dxf_save() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let at = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
|
||||
.unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
s.cancel();
|
||||
// 2007 DXF: older versions keep a multileader as a LEADER and an MTEXT.
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
|
||||
let reopened = cadcraft_io::read_dxf(&cadcraft_io::write_with(s.doc().unwrap(), "x.dxf", &opts).unwrap()).unwrap();
|
||||
let saved =
|
||||
reopened.model.iter().find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None });
|
||||
assert_eq!(saved.as_deref(), Some("edited\\Psecond line"));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
/// A multileader on a locked layer is refused when it is picked, before the editor opens, and
|
||||
/// Properties leaves it alone.
|
||||
#[test]
|
||||
fn a_multileader_on_a_locked_layer_is_refused_when_picked() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("The object is on a locked layer."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Select an annotation object");
|
||||
s.cancel();
|
||||
let r = s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_too_long_for_the_editor_is_asked_for_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
|
||||
assert_eq!(text_of(&s), long);
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long for the editor; enter the new text at the command line."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Enter new text");
|
||||
// Enter keeps it whole; a typed answer still replaces it, as for any other text.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(text_of(&s), long, "the text is not shortened");
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("short".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "short");
|
||||
s.cancel();
|
||||
}
|
||||
@@ -190,11 +190,6 @@ fn reload_unload_path_and_detach() {
|
||||
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
|
||||
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
|
||||
|
||||
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
|
||||
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
|
||||
|
||||
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
|
||||
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
|
||||
assert!(s.doc().unwrap().block("site|Tree").is_none());
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
+3
-28
@@ -43,16 +43,13 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
rerun_if_changed(&manifest)?;
|
||||
// Resolve the checkout root once so every manifest file can be confined inside it.
|
||||
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
|
||||
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
|
||||
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
// The line's text is not repeated: it comes from the checkout and goes to the build output.
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line {n}"));
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
|
||||
@@ -76,28 +73,6 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most a manifest may hold; the real one is a few KiB.
|
||||
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
|
||||
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
|
||||
use std::io::Read as _;
|
||||
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !file.metadata().is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
|
||||
///
|
||||
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
|
||||
|
||||
@@ -33,13 +33,21 @@ pub(crate) fn is_mark(c: char) -> bool {
|
||||
}
|
||||
|
||||
/// Base direction of a paragraph from its first strong character (rules P2/P3): `Some(true)`
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character.
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character. As rule
|
||||
/// P2 requires, characters between an isolate initiator and its matching PDI (or the end of the
|
||||
/// paragraph, when unmatched) are skipped.
|
||||
pub fn base_rtl(s: &str) -> Option<bool> {
|
||||
s.chars().find_map(|c| match bidi_class(c) {
|
||||
BidiClass::L => Some(false),
|
||||
BidiClass::R | BidiClass::AL => Some(true),
|
||||
_ => None,
|
||||
})
|
||||
let mut isolate_depth: usize = 0;
|
||||
for c in s.chars() {
|
||||
match bidi_class(c) {
|
||||
BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => isolate_depth = isolate_depth.saturating_add(1),
|
||||
BidiClass::PDI => isolate_depth = isolate_depth.saturating_sub(1),
|
||||
BidiClass::L if isolate_depth == 0 => return Some(false),
|
||||
BidiClass::R | BidiClass::AL if isolate_depth == 0 => return Some(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn para_level(base: Option<bool>) -> Option<Level> {
|
||||
@@ -349,14 +357,24 @@ pub fn presentation_forms(chars: &[char], keep: impl Fn(char) -> bool) -> Vec<(c
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let form = forms.get(i).copied().flatten();
|
||||
// Combining marks (harakat) between lam and alef are transparent for joining, so they
|
||||
// don't block the ligature; they stay after it, in logical order.
|
||||
let alef_at =
|
||||
if c == '\u{0644}' { chars.get(i + 1..).and_then(|rest| rest.iter().position(|ch| !is_mark(*ch))).map(|p| i + 1 + p) } else { None };
|
||||
if c == '\u{0644}'
|
||||
&& let Some(f) = form
|
||||
&& let Some(&a) = chars.get(i + 1)
|
||||
&& let Some(j) = alef_at
|
||||
&& let Some(&a) = chars.get(j)
|
||||
&& let Some(lig) = lam_alef(a, matches!(f, Form::Medial | Form::Final))
|
||||
&& keep(lig)
|
||||
{
|
||||
out.push((lig, i));
|
||||
i += 2;
|
||||
for k in i + 1..j {
|
||||
if let Some(&m) = chars.get(k) {
|
||||
out.push((m, k));
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
let shaped = form.and_then(|f| presentation_form(c, f)).filter(|p| keep(*p)).unwrap_or(c);
|
||||
@@ -442,6 +460,48 @@ mod tests {
|
||||
assert!(visual_runs(&[], None).is_empty() && char_levels("", None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_direction_skips_isolate_contents() {
|
||||
// RLI שלום PDI abc: the Hebrew sits inside the isolate, so the first strong character is "a".
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069} abc"), Some(false));
|
||||
assert_eq!(base_rtl("\u{2066}abc\u{2069} שלום"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2068}שלום\u{2069}abc"), Some(false));
|
||||
// Nested isolates: only the outermost PDI closes the outer one.
|
||||
assert_eq!(base_rtl("\u{2066}a\u{2067}ב\u{2069}a\u{2069}ב"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2067}\u{2066}a\u{2069}ב\u{2069}a"), Some(false));
|
||||
// An unmatched initiator isolates the rest of the paragraph.
|
||||
assert_eq!(base_rtl("\u{2067}שלום"), None);
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069}"), None);
|
||||
// A stray PDI does not close anything and does not underflow.
|
||||
assert_eq!(base_rtl("\u{2069}\u{2069}שלום"), Some(true));
|
||||
// No strong character outside the isolates.
|
||||
assert_eq!(base_rtl("12 \u{2066}abc\u{2069} 34"), None);
|
||||
// Without isolates nothing changes.
|
||||
assert_eq!(base_rtl(" 123 سلام abc"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harakat_between_lam_and_alef_keep_the_ligature() {
|
||||
let run = |s: &str| -> Vec<(char, usize)> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
presentation_forms(&chars, |_| true)
|
||||
};
|
||||
// Lam + fatha + alef: the ligature, then the fatha (logical order kept).
|
||||
assert_eq!(run("ل\u{064E}ا"), vec![('\u{FEFB}', 0), ('\u{064E}', 1)]);
|
||||
// Word-medial: س + lam + fatha + shadda + alef gives the final ligature, marks after it.
|
||||
assert_eq!(run("سل\u{064E}\u{0651}ا"), vec![('\u{FEB3}', 0), ('\u{FEFC}', 1), ('\u{064E}', 2), ('\u{0651}', 3)]);
|
||||
// Plain lam + alef is unchanged.
|
||||
assert_eq!(run("لا"), vec![('\u{FEFB}', 0)]);
|
||||
// Mark after the alef is not between them: unchanged behaviour.
|
||||
assert_eq!(run("لا\u{064E}"), vec![('\u{FEFB}', 0), ('\u{064E}', 2)]);
|
||||
// Lam + mark + a non-alef letter: no ligature, the lam joins forward as before.
|
||||
assert_eq!(run("ل\u{064E}م"), vec![('\u{FEDF}', 0), ('\u{064E}', 1), ('\u{FEE2}', 2)]);
|
||||
// Lam + mark at the end: no alef, nothing breaks.
|
||||
assert_eq!(run("ل\u{064E}"), vec![('\u{FEDD}', 0), ('\u{064E}', 1)]);
|
||||
// A non-mark between lam and alef blocks the ligature.
|
||||
assert_eq!(run("ل-ا").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for s in [
|
||||
|
||||
+212
-9
@@ -9,6 +9,8 @@ pub(crate) mod para;
|
||||
/// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks.
|
||||
/// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped.
|
||||
pub fn plain_mtext(s: &str) -> String {
|
||||
let s = unescape_codes(s);
|
||||
let s = s.as_ref();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -63,7 +65,7 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
i += 7;
|
||||
}
|
||||
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
|
||||
_ => {
|
||||
_ if takes_arg(code) => {
|
||||
let mut j = i + 2;
|
||||
while let Some(&ch) = chars.get(j) {
|
||||
if ch == ';' {
|
||||
@@ -73,6 +75,8 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
// An unknown code has no argument: drop the backslash and the code only.
|
||||
_ => i += 2,
|
||||
}
|
||||
}
|
||||
'{' | '}' => i += 1,
|
||||
@@ -180,11 +184,96 @@ enum Atom {
|
||||
Para(usize),
|
||||
}
|
||||
|
||||
/// Upper bound on parsed atoms (hostile contents).
|
||||
/// Upper bound on parsed atoms (words, spaces and paragraph breaks) per MTEXT. Hostile contents
|
||||
/// are cut here: once more than this many atoms are parsed, `parse` stops reading and the rest
|
||||
/// of the contents is silently dropped from the layout. There is no warning to the caller.
|
||||
const MAX_ATOMS: usize = 200_000;
|
||||
/// Scale of stacked text relative to the surrounding text.
|
||||
const STACK_SCALE: f64 = 0.7;
|
||||
|
||||
/// Codes that take an argument terminated by `;` (besides `\S`, which has its own scan). Any
|
||||
/// other code stands alone, so a missing `;` must not swallow the text after it.
|
||||
pub(crate) fn takes_arg(code: char) -> bool {
|
||||
matches!(code, 'f' | 'F' | 'H' | 'W' | 'Q' | 'T' | 'A' | 'C' | 'c' | 'p')
|
||||
}
|
||||
|
||||
/// The line-spacing factor to lay out with: AutoCAD's 0.25..=4 range, 1 for a missing or
|
||||
/// non-finite value.
|
||||
pub(crate) fn sane_line_spacing(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.clamp(0.25, 4.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Four hex digits at `chars[at..at + 4]`.
|
||||
fn hex4(chars: &[char], at: usize) -> Option<u32> {
|
||||
let digits = chars.get(at..at.checked_add(4)?)?;
|
||||
digits.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// Push a decoded character so that the tokenizers read it as text, never as markup.
|
||||
fn push_literal(out: &mut String, c: char) {
|
||||
if matches!(c, '\\' | '{' | '}') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
/// Decode `\U+hhhh` (one UTF-16 unit; a surrogate pair gives one character, a lone surrogate
|
||||
/// U+FFFD) and `\M+nhhhh` (double-byte East Asian characters) to the characters they stand
|
||||
/// for. Malformed escapes are left for the tokenizers, which drop the backslash and the code.
|
||||
/// An escaped backslash (`\\U+0041`) is not an escape.
|
||||
pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return std::borrow::Cow::Borrowed(s);
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (n, plus) = (chars.get(i + 1).copied(), chars.get(i + 2).copied());
|
||||
if n == Some('U') && plus == Some('+') {
|
||||
if let Some(unit) = hex4(&chars, i + 3) {
|
||||
i += 7;
|
||||
let low = |at: usize| {
|
||||
(chars.get(at) == Some(&'\\') && chars.get(at + 1) == Some(&'U') && chars.get(at + 2) == Some(&'+'))
|
||||
.then(|| hex4(&chars, at + 3))
|
||||
.flatten()
|
||||
.filter(|u| (0xDC00..0xE000).contains(u))
|
||||
};
|
||||
let ch = if (0xD800..0xDC00).contains(&unit) {
|
||||
match low(i) {
|
||||
Some(lo) => {
|
||||
i += 7;
|
||||
char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (lo - 0xDC00))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
char::from_u32(unit)
|
||||
};
|
||||
push_literal(&mut out, ch.unwrap_or('\u{fffd}'));
|
||||
continue;
|
||||
}
|
||||
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
|
||||
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
// Any other backslash pair stays as it is (and an escaped backslash stays one).
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
if let Some(&next) = chars.get(i) {
|
||||
out.push(next);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn arg(chars: &[char], from: usize) -> (String, usize) {
|
||||
let mut j = from;
|
||||
let mut a = String::new();
|
||||
@@ -236,6 +325,8 @@ fn font_switch(a: &str) -> Option<TextFont> {
|
||||
/// Parse MTEXT contents into words, spaces and paragraph breaks with their formats.
|
||||
fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let h_min = base.height * 1e-3;
|
||||
let h_max = if (base.height * 1e3).is_finite() { base.height * 1e3 } else { f64::MAX };
|
||||
let mut fmts = vec![base];
|
||||
let mut cur = 0usize;
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
@@ -342,14 +433,22 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.0 && *v < 1e6) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { f.height * v } else { v });
|
||||
// Relative heights compound; keep the result within 1e-3..1e3 times the
|
||||
// MTEXT's own height, never infinite.
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { (f.height * v).min(h_max).max(h_min) } else { v });
|
||||
}
|
||||
}
|
||||
'W' => {
|
||||
let (a, n) = arg(&chars, i);
|
||||
i = n;
|
||||
if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = v);
|
||||
// `\W2x;` multiplies the current factor (like `\H2x;`); `\W2;` sets it.
|
||||
let (rel, v) = match a.trim().strip_suffix(['x', 'X']) {
|
||||
Some(r) => (true, num(r)),
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
// Relative factors compound; keep the result within the absolute range.
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = if rel { (f.wf * v).clamp(0.01, 100.0) } else { v });
|
||||
}
|
||||
}
|
||||
'Q' => {
|
||||
@@ -381,11 +480,13 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.valign = v);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// \T, \p and unknown codes: argument up to ';'.
|
||||
_ if takes_arg(code) => {
|
||||
// \T and \p: argument up to ';'.
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code are dropped.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'{' => {
|
||||
@@ -692,11 +793,14 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec<Line
|
||||
/// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the
|
||||
/// column width and the attachment point.
|
||||
pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
let contents = unescape_codes(contents);
|
||||
let contents = contents.as_ref();
|
||||
let h = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
|
||||
let wf = if p.width_factor.is_finite() && p.width_factor > 0.0 { p.width_factor } else { 1.0 };
|
||||
let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 };
|
||||
let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 };
|
||||
let oblique = if p.oblique.is_finite() { p.oblique } else { 0.0 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique, valign: 1 };
|
||||
if para::needed(contents, p) {
|
||||
return para::layout(contents, p, &base);
|
||||
}
|
||||
@@ -719,7 +823,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
for l in &mut lines {
|
||||
l.reorder_bidi(bases.get(l.para).copied().flatten());
|
||||
}
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
let ls = sane_line_spacing(p.line_spacing);
|
||||
// Baselines below the top of the first line.
|
||||
let mut baselines = Vec::with_capacity(lines.len());
|
||||
let mut y = 0.0;
|
||||
@@ -803,6 +907,105 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_are_decoded_in_every_parser() {
|
||||
// The escape has no `;`, so it must not swallow the rest of the text.
|
||||
assert_eq!(plain_mtext("A\\U+00B0 B C;D"), "A\u{b0} B C;D");
|
||||
assert_eq!(lay("A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
// The paragraph path (`\p`) and the plain path agree.
|
||||
assert_eq!(lay("\\pxql;A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
let scanned = para::Paragraphs::scan("\\U+00B0\\pi1;x\\U+0041 y;z");
|
||||
assert!(scanned.formatted());
|
||||
// A surrogate pair is one character; a lone surrogate is U+FFFD.
|
||||
assert_eq!(plain_mtext("\\U+D83D\\U+DE00!"), "\u{1f600}!");
|
||||
assert_eq!(lay("\\U+D83D\\U+DE00!", 0.0).lines, vec!["\u{1f600}!"]);
|
||||
assert_eq!(plain_mtext("a\\U+D83Db"), "a\u{fffd}b");
|
||||
assert_eq!(plain_mtext("a\\U+DE00b\\U+D83D"), "a\u{fffd}b\u{fffd}");
|
||||
// A decoded backslash or brace is text, not markup; an escaped backslash is not an escape.
|
||||
assert_eq!(plain_mtext("\\U+005CH2;x"), "\\H2;x");
|
||||
assert_eq!(plain_mtext("\\\\U+0041"), "\\U+0041");
|
||||
// `\M+n` double-byte characters, decoded like the DXF reader does.
|
||||
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
|
||||
// Malformed escapes lose only the backslash and the code.
|
||||
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
|
||||
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_without_semicolon_keeps_the_text() {
|
||||
assert_eq!(plain_mtext("A\\Zbc d"), "Abc d");
|
||||
assert_eq!(lay("A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
assert_eq!(lay("\\pxql;A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
// A trailing lone backslash is dropped.
|
||||
assert_eq!(plain_mtext("A\\"), "A");
|
||||
// Codes with an argument still take it up to the `;`, or to the end.
|
||||
assert_eq!(plain_mtext("A\\H2;B"), "AB");
|
||||
assert_eq!(plain_mtext("A\\H2 B"), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_oblique_and_line_spacing_are_repaired() {
|
||||
let run = |oblique: f64, spacing: f64| {
|
||||
let p = MTextParams { oblique, line_spacing: spacing, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
layout_mtext_with("AB\\PCD", &p)
|
||||
};
|
||||
let finite =
|
||||
|l: &MTextLayout| l.bounds.min.x.is_finite() && l.bounds.max.x.is_finite() && l.bounds.min.y.is_finite() && l.bounds.max.y.is_finite();
|
||||
let reference = run(0.0, 1.0);
|
||||
for o in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let l = run(o, 1.0);
|
||||
assert!(finite(&l), "oblique {o}");
|
||||
assert_eq!(l.bounds, reference.bounds, "oblique {o}");
|
||||
}
|
||||
for s in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -2.0] {
|
||||
let l = run(0.0, s);
|
||||
assert!(finite(&l), "spacing {s}");
|
||||
assert_eq!(l.bounds, reference.bounds, "spacing {s}");
|
||||
}
|
||||
// Spacing is clamped to AutoCAD's 0.25..=4 range.
|
||||
assert_eq!(run(0.0, 1e9).bounds, run(0.0, 4.0).bounds);
|
||||
assert_eq!(run(0.0, 1e-9).bounds, run(0.0, 0.25).bounds);
|
||||
assert!(run(0.0, 4.0).bounds.height() > reference.bounds.height());
|
||||
// The paragraph layout takes the same path.
|
||||
let p = MTextParams { oblique: f64::NAN, line_spacing: f64::NAN, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
assert!(finite(&layout_mtext_with("\\pxql;AB\\PCD", &p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_heights_do_not_run_away() {
|
||||
let src = format!("{}A", "\\H999999x;".repeat(60));
|
||||
let l = lay(&src, 0.0);
|
||||
assert!(l.bounds.height().is_finite() && l.bounds.width().is_finite());
|
||||
assert!(l.bounds.height() <= 1e3 * 1.5, "{}", l.bounds.height());
|
||||
assert!(l.bounds.height() > 100.0, "the glyph must not vanish: {}", l.bounds.height());
|
||||
// Shrinking is bounded too.
|
||||
let tiny = lay(&format!("{}A", "\\H0.000001x;".repeat(60)), 0.0);
|
||||
assert!(tiny.bounds.height() > 0.0);
|
||||
}
|
||||
|
||||
/// The width factor of the last format `parse` derived.
|
||||
fn last_wf(contents: &str, base_wf: f64) -> f64 {
|
||||
let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: base_wf, oblique: 0.0, valign: 1 };
|
||||
parse(contents, base).1.last().map_or(f64::NAN, |f| f.wf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_factor_x_is_relative() {
|
||||
assert_eq!(last_wf("\\W2;a", 1.5), 2.0, "absolute sets the factor");
|
||||
assert_eq!(last_wf("\\W2x;a", 1.5), 3.0, "x multiplies the current factor");
|
||||
assert_eq!(last_wf("\\W2;\\W2x;a", 1.0), 4.0, "relative builds on the last absolute");
|
||||
assert_eq!(last_wf("\\W0.5X;a", 1.0), 0.5, "capital X too");
|
||||
// Hostile: a long relative chain stays inside the absolute range.
|
||||
let chain = "\\W99x;".repeat(50);
|
||||
assert_eq!(last_wf(&chain, 1.0), 100.0);
|
||||
let down = "\\W0.02x;".repeat(50);
|
||||
assert_eq!(last_wf(&down, 1.0), 0.01);
|
||||
// Out of range or not a number: ignored.
|
||||
assert_eq!(last_wf("\\W1e999x;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\WNaNx;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\Wx;a", 1.5), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_to_width() {
|
||||
let l = layout_mtext("the quick brown fox jumps over the lazy dog", Vec2::ZERO, 1.0, 8.0, 1, 0.0, 1.0);
|
||||
|
||||
@@ -264,6 +264,7 @@ pub struct Paragraphs {
|
||||
|
||||
impl Paragraphs {
|
||||
pub fn scan(contents: &str) -> Paragraphs {
|
||||
let contents = super::unescape_codes(contents);
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut out = Paragraphs { states: vec![ParaProps::default()], ..Default::default() };
|
||||
let mut cur = 0usize;
|
||||
@@ -303,10 +304,12 @@ impl Paragraphs {
|
||||
cur = out.states.len() - 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
_ if super::takes_arg(code) => {
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code go.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'\n' => {
|
||||
@@ -604,7 +607,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
let lines = LineBuilder::new(¶s, &fmts, base, width, h).build(&atoms);
|
||||
|
||||
// Vertical placement: baselines below the top of each line's column.
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
let ls = super::sane_line_spacing(p.line_spacing);
|
||||
let cols = p.columns.as_ref().filter(|_| width > 0.0);
|
||||
let max_cols = match cols {
|
||||
Some(c) if c.dynamic => MAX_COLUMNS,
|
||||
|
||||
+168
-3
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use crate::spline::{basis, find_span};
|
||||
use crate::spline::{MAX_STACK_DEGREE, basis, find_span};
|
||||
use crate::{EPS, Ellipse, Spline, TAU, Vec2};
|
||||
|
||||
/// Highest degree the editing operations work with.
|
||||
@@ -88,6 +88,34 @@ fn elevate_bezier(p: &[H]) -> Vec<H> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Basis functions of degree `p` in `n[..=p]` and of degree `p - 1` in `lower[..p]` for `p >= 1`,
|
||||
/// in one Cox-de Boor sweep: the degree `p - 1` values are the state just before the last step,
|
||||
/// so they match [`basis`] exactly. `left` and `right` are scratch of `p + 1` values; the outputs
|
||||
/// stay untouched when a slice is too short.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn basis_pair_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], lower: &mut [f64], left: &mut [f64], right: &mut [f64]) {
|
||||
if p == 0 || n.len() <= p || lower.len() < p || left.len() <= p || right.len() <= p {
|
||||
return;
|
||||
}
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
|
||||
left[j] = u - k(span as isize + 1 - j as isize);
|
||||
right[j] = k(span as isize + j as isize) - u;
|
||||
if j == p {
|
||||
lower[..p].copy_from_slice(&n[..p]);
|
||||
}
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
let den = right[r + 1] + left[j - r];
|
||||
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
|
||||
n[r] = saved + right[r + 1] * tmp;
|
||||
saved = left[j - r] * tmp;
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
}
|
||||
|
||||
impl Spline {
|
||||
fn weight(&self, i: usize) -> f64 {
|
||||
if self.weights.is_empty() { 1.0 } else { self.weights.get(i).copied().unwrap_or(1.0) }
|
||||
@@ -130,6 +158,8 @@ impl Spline {
|
||||
/// First derivative without re-validating the spline. The caller must have confirmed
|
||||
/// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the
|
||||
/// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407).
|
||||
/// Up to degree [`MAX_STACK_DEGREE`] both bases come from one stack-only sweep; higher degrees
|
||||
/// compute them on the heap.
|
||||
fn derivative_unchecked(&self, t: f64) -> Vec2 {
|
||||
let d = self.velocity(t);
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
@@ -142,8 +172,17 @@ impl Spline {
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
let nb = basis(span, t, p, &self.knots);
|
||||
let nd = basis(span, t, p - 1, &self.knots);
|
||||
let (mut sb, mut sd, mut sl, mut sr) =
|
||||
([0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1]);
|
||||
let (hb, hd);
|
||||
let (nb, nd): (&[f64], &[f64]) = if (1..=MAX_STACK_DEGREE).contains(&p) {
|
||||
basis_pair_into(span, t, p, &self.knots, &mut sb, &mut sd, &mut sl, &mut sr);
|
||||
(&sb, &sd)
|
||||
} else {
|
||||
hb = basis(span, t, p, &self.knots);
|
||||
hd = basis(span, t, p - 1, &self.knots);
|
||||
(&hb, &hd)
|
||||
};
|
||||
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
|
||||
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
|
||||
@@ -624,6 +663,132 @@ mod tests {
|
||||
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
|
||||
}
|
||||
|
||||
/// The previous implementation (two heap-allocated bases), kept as the oracle for the
|
||||
/// allocation-free `derivative_unchecked`.
|
||||
impl Spline {
|
||||
fn derivative_unchecked_old(&self, t: f64) -> Vec2 {
|
||||
let p = self.degree;
|
||||
let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO };
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
let nb = basis(span, t, p, &self.knots);
|
||||
let nd = basis(span, t, p - 1, &self.knots);
|
||||
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
|
||||
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
|
||||
for j in 0..=p {
|
||||
let Some(i) = (span + j).checked_sub(p) else { continue };
|
||||
let Some(c) = self.control.get(i) else { continue };
|
||||
let wi = self.weight(i);
|
||||
let n0 = nb.get(j).copied().unwrap_or(0.0);
|
||||
// N'_{i,p} = p (N_{i,p-1} / (u_{i+p} - u_i) - N_{i+1,p-1} / (u_{i+p+1} - u_{i+1})).
|
||||
let left = if j >= 1 { ratio(nd.get(j - 1), k(i + p) - k(i)) } else { 0.0 };
|
||||
let right = if j < p { ratio(nd.get(j), k(i + p + 1) - k(i + 1)) } else { 0.0 };
|
||||
let dn = p as f64 * (left - right);
|
||||
a += *c * (n0 * wi);
|
||||
w += n0 * wi;
|
||||
da += *c * (dn * wi);
|
||||
dw += dn * wi;
|
||||
}
|
||||
if w.abs() < 1e-300 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let d = (da - a / w * dw) / w;
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
}
|
||||
}
|
||||
|
||||
struct XorShift(u64);
|
||||
impl XorShift {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid random spline of degree `p`: clamped or unclamped knots, repeated interior knots,
|
||||
/// and non-rational, rational or zero/negative weights.
|
||||
fn random_spline(r: &mut XorShift, p: usize) -> Spline {
|
||||
let n = p + 1 + r.below(8);
|
||||
let control = (0..n).map(|_| Vec2::new(r.unit() * 100.0 - 50.0, r.unit() * 100.0 - 50.0)).collect();
|
||||
let total = n + p + 1;
|
||||
let mut knots: Vec<f64> =
|
||||
(0..total).map(|_| (r.unit() * 8.0).floor() * if r.below(3) == 0 { 0.5 } else { 1.0 } + r.unit() * f64::from(r.below(2) == 0)).collect();
|
||||
knots.sort_by(f64::total_cmp);
|
||||
if r.below(2) == 0 {
|
||||
// Clamp both ends.
|
||||
let (lo, hi) = (knots[p], knots[n]);
|
||||
knots[..=p].fill(lo);
|
||||
knots[n..].fill(hi);
|
||||
}
|
||||
let weights = match r.below(4) {
|
||||
0 => Vec::new(),
|
||||
1 => (0..n).map(|_| 0.2 + r.unit() * 4.0).collect(),
|
||||
2 => (0..n).map(|_| if r.below(5) == 0 { 0.0 } else { r.unit() * 2.0 - 0.5 }).collect(),
|
||||
_ => (0..n).map(|_| 1.0 + r.unit() * 1e-9).collect(),
|
||||
};
|
||||
Spline { degree: p, knots, control, weights, ..Spline::default() }
|
||||
}
|
||||
|
||||
fn bits(v: Vec2) -> (u64, u64) {
|
||||
(v.x.to_bits(), v.y.to_bits())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_is_bit_identical_to_the_heap_implementation() {
|
||||
let mut r = XorShift(0x9E37_79B9_7F4A_7C15);
|
||||
let mut zero_results = 0usize;
|
||||
for p in 1..=12usize {
|
||||
let mut compared = 0usize;
|
||||
for _ in 0..400 {
|
||||
let s = random_spline(&mut r, p);
|
||||
if !s.is_valid() {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = s.domain();
|
||||
let mut ts = vec![lo, hi, 0.5 * (lo + hi), lo - 1.0, hi + 1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
ts.extend(s.knots.iter().copied());
|
||||
ts.extend(s.knots.iter().map(|k| k.next_up()));
|
||||
ts.extend(s.knots.iter().map(|k| k.next_down()));
|
||||
ts.extend((0..8).map(|_| lo + (hi - lo) * r.unit()));
|
||||
for t in ts {
|
||||
let (new, old) = (s.derivative_unchecked(t), s.derivative_unchecked_old(t));
|
||||
assert_eq!(bits(new), bits(old), "degree {p}, t = {t}, knots {:?}, weights {:?}", s.knots, s.weights);
|
||||
compared += 1;
|
||||
zero_results += usize::from(old == Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
assert!(compared > 1000, "degree {p}: only {compared} comparisons ran");
|
||||
}
|
||||
// The zero-vector exits (vanishing weight sum, non-finite result) are exercised too.
|
||||
assert!(zero_results > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_dispatch_around_the_stack_limit_does_not_change_results() {
|
||||
// The last stack degree and the first heap degree both match the oracle on a clamped
|
||||
// uniform spline (the heap degree runs the unchanged old path; this checks the dispatch).
|
||||
for p in [MAX_STACK_DEGREE, MAX_STACK_DEGREE + 1] {
|
||||
let ctrl: Vec<Vec2> = (0..p + 4).map(|i| Vec2::new(i as f64, (i * i % 7) as f64)).collect();
|
||||
let s = Spline::from_control(ctrl, p);
|
||||
assert_eq!(s.degree, p);
|
||||
let (lo, hi) = s.domain();
|
||||
for i in 0..=50 {
|
||||
let t = lo + (hi - lo) * f64::from(i) / 50.0;
|
||||
assert_eq!(bits(s.derivative_unchecked(t)), bits(s.derivative_unchecked_old(t)), "degree {p}, t = {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_matches_finite_differences() {
|
||||
let mut s = cubic();
|
||||
|
||||
+283
-62
@@ -75,7 +75,108 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound on candidate-pair *visits*. Every visit counts, including repeats of a pair that shares several grid
|
||||
/// cells, not only the distinct pairs actually tested. A long segment replicated across many
|
||||
/// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
|
||||
/// repeated work unbounded (CWE-407).
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
/// Upper bound on distinct segment pairs tested for a crossing.
|
||||
const MAX_PAIRS_TESTED: usize = 20_000_000;
|
||||
|
||||
/// Split parameters of every segment (always `0` and `1`, plus every crossing), found with a uniform grid.
|
||||
fn pair_splits(segs: &[(Vec2, Vec2)], bb: &Bounds2) -> Option<Vec<Vec<f64>>> {
|
||||
pair_splits_limited(segs, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED)
|
||||
}
|
||||
|
||||
/// `pair_splits` with explicit budgets: `None` once more than `max_visits` candidate pairs were visited
|
||||
/// or more than `max_pairs` distinct pairs were tested.
|
||||
fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
// Cell lists in a flat grid, indexed `gx * cells + gy`; each list is in ascending segment order.
|
||||
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); cells * cells];
|
||||
// The lowest cell of each segment's cell range; the lowest cell two segments share is the
|
||||
// componentwise maximum of their two.
|
||||
let mut low: Vec<(usize, usize)> = vec![(0, 0); n];
|
||||
let mut is_long = vec![false; n];
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if let Some(l) = low.get_mut(i) {
|
||||
*l = (x0, y0);
|
||||
}
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: too many cells to register in; it is tested against every segment below.
|
||||
long.push(i);
|
||||
if let Some(f) = is_long.get_mut(i) {
|
||||
*f = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
if let Some(list) = grid.get_mut(gx * cells + gy) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested: usize = 0;
|
||||
let mut visits: u64 = 0;
|
||||
for (c, list) in grid.iter().enumerate() {
|
||||
let (gx, gy) = (c / cells, c % cells);
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
let Some(&(ix, iy)) = low.get(i) else { continue };
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > max_visits {
|
||||
return None;
|
||||
}
|
||||
let Some(&(jx, jy)) = low.get(j) else { continue };
|
||||
// A pair sharing several cells is tested once, in the lowest cell both occupy.
|
||||
if i == j || gx != ix.max(jx) || gy != iy.max(jy) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A long segment is in no cell list. Two long segments are tested once, from the lower index.
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || (j < i && is_long.get(j).copied().unwrap_or(false)) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(splits)
|
||||
}
|
||||
|
||||
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
build_with(polys, tol, pair_splits)
|
||||
}
|
||||
|
||||
/// `build` with the crossing search injected, so tests can run the old and the new search on the same input.
|
||||
fn build_with(polys: &[Vec<Vec2>], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)], &Bounds2) -> Option<Vec<Vec<f64>>>) -> Option<Graph> {
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
let mut owner: Vec<usize> = Vec::new();
|
||||
for (k, pl) in polys.iter().enumerate() {
|
||||
@@ -96,70 +197,9 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
if segs.is_empty() || segs.len() > MAX_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
// Split points per segment (parameters), found with a uniform grid.
|
||||
let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]));
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: too many cells to register in; it is tested against every segment below.
|
||||
long.push(i);
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
// Count every candidate-pair *visit*, including repeats of a pair that shares several grid
|
||||
// cells, not only the distinct pairs actually tested. A long segment replicated across many
|
||||
// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
|
||||
// repeated hashing unbounded (CWE-407).
|
||||
let mut visits: u64 = 0;
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > MAX_PAIR_VISITS {
|
||||
return None;
|
||||
}
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let splits = find_splits(&segs, &bb)?;
|
||||
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
|
||||
@@ -630,6 +670,187 @@ mod tests {
|
||||
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
|
||||
}
|
||||
|
||||
/// The crossing search before the lowest-shared-cell rule: a `HashSet` of tested pairs over a `HashMap` grid.
|
||||
fn pair_splits_oracle(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
long.push(i);
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
let mut visits: u64 = 0;
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > max_visits {
|
||||
return None;
|
||||
}
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
if tested.len() > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
if tested.len() > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(splits)
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Random segments: short ones, some long ones, and some on a coarse lattice (shared ends, overlaps).
|
||||
fn random_segments(r: &mut Rng, n: usize, size: f64, long_every: u64) -> Vec<(Vec2, Vec2)> {
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..n {
|
||||
let a = Vec2::new(r.unit() * size, r.unit() * size);
|
||||
let seg = match r.next() % 4 {
|
||||
0 if long_every > 0 && r.next().is_multiple_of(long_every) => (
|
||||
Vec2::new(r.unit() * size * 0.02, r.unit() * size * 0.02),
|
||||
Vec2::new(size * (0.98 + r.unit() * 0.02), size * (0.98 + r.unit() * 0.02)),
|
||||
),
|
||||
1 => {
|
||||
let q = |r: &mut Rng| (r.next() % 9) as f64 * size / 8.0;
|
||||
(Vec2::new(q(r), q(r)), Vec2::new(q(r), q(r)))
|
||||
}
|
||||
_ => (a, a + Vec2::new((r.unit() - 0.5) * size * 0.1, (r.unit() - 0.5) * size * 0.1)),
|
||||
};
|
||||
if !seg.0.near(seg.1, 1e-9) {
|
||||
out.push(seg);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sorted_bits(splits: &[Vec<f64>]) -> Vec<Vec<u64>> {
|
||||
splits
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut v = v.clone();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.iter().map(|x| x.to_bits()).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bounds_of(segs: &[(Vec2, Vec2)]) -> Bounds2 {
|
||||
Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]))
|
||||
}
|
||||
|
||||
fn assert_same_search(segs: &[(Vec2, Vec2)], max_visits: u64, max_pairs: usize) {
|
||||
let bb = bounds_of(segs);
|
||||
let old = pair_splits_oracle(segs, &bb, max_visits, max_pairs);
|
||||
let new = pair_splits_limited(segs, &bb, max_visits, max_pairs);
|
||||
assert_eq!(old.as_deref().map(sorted_bits), new.as_deref().map(sorted_bits), "n={} visits<={max_visits} pairs<={max_pairs}", segs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_search_matches_oracle_with_budgets() {
|
||||
// Small inputs in few cells: many pairs share several cells. Sweeping each budget over
|
||||
// 0..=400 crosses its exhaustion point for most seeds, including the exact boundary.
|
||||
for seed in 1..=150u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
|
||||
let n = 2 + (r.next() % 28) as usize;
|
||||
let segs = random_segments(&mut r, n, 100.0, 0);
|
||||
if segs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for v in 0..=400u64 {
|
||||
assert_same_search(&segs, v, usize::MAX);
|
||||
}
|
||||
for p in 0..=400usize {
|
||||
assert_same_search(&segs, u64::MAX, p);
|
||||
}
|
||||
assert_same_search(&segs, 60_000_000, 20_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_search_matches_oracle_with_long_segments() {
|
||||
// Around 5000 segments give a 71x71 grid, so a diagonal covers more than 4096 cells and takes the long path.
|
||||
for seed in 1..=3u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0xD1B5_4A32_D192_ED03) | 1);
|
||||
let n = 5_000 + (r.next() % 500) as usize;
|
||||
let segs = random_segments(&mut r, n, 1000.0, 40);
|
||||
let bb = bounds_of(&segs);
|
||||
let cells = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
assert!(cells >= 64, "grid too coarse to reach the long path");
|
||||
assert_same_search(&segs, 60_000_000, 20_000_000);
|
||||
// Budgets exhausted in the cell phase (visits, then pairs).
|
||||
assert_same_search(&segs, 3_000, 20_000_000);
|
||||
assert_same_search(&segs, 60_000_000, 200);
|
||||
assert!(pair_splits_limited(&segs, &bb, 3_000, 20_000_000).is_none());
|
||||
assert!(pair_splits_limited(&segs, &bb, 60_000_000, 200).is_none());
|
||||
let (cw, ch) = ((bb.width() / cells as f64).max(1e-12), (bb.height() / cells as f64).max(1e-12));
|
||||
let span = |a: f64, b: f64, o: f64, w: f64| {
|
||||
(((b.max(a) - o) / w).floor().min((cells - 1) as f64) - ((b.min(a) - o) / w).floor().max(0.0) + 1.0) as usize
|
||||
};
|
||||
let long = segs.iter().filter(|(a, b)| span(a.x, b.x, bb.min.x, cw) * span(a.y, b.y, bb.min.y, ch) > 4096).count();
|
||||
assert!(long > 0, "seed {seed}: no segment takes the long path");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_matches_oracle_on_random_drawings() {
|
||||
for seed in 1..=60u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0xA076_1D64_78BD_642F) | 1);
|
||||
let n = 5 + (r.next() % 400) as usize;
|
||||
let polys: Vec<Vec<Vec2>> = random_segments(&mut r, n, 100.0, 0).into_iter().map(|(a, b)| vec![a, b]).collect();
|
||||
let old = build_with(&polys, 1e-6, |s, bb| pair_splits_oracle(s, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED));
|
||||
let new = build(&polys, 1e-6);
|
||||
let (Some(old), Some(new)) = (old, new) else { panic!("seed {seed}: build failed") };
|
||||
assert_eq!(old.pts, new.pts, "seed {seed}");
|
||||
assert_eq!(old.adj, new.adj, "seed {seed}");
|
||||
assert_eq!(old.src, new.src, "seed {seed}");
|
||||
assert_eq!(old.edge_src, new.edge_src, "seed {seed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_segment_crossing_in_unsampled_cell_is_split() {
|
||||
// A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.
|
||||
|
||||
@@ -347,21 +347,64 @@ fn basis_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], left:
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting.
|
||||
/// Gaussian elimination with partial pivoting. The interpolation and least-squares matrices are
|
||||
/// banded, so every loop is limited to the band (lower width `kl`, upper width `ku`, measured from
|
||||
/// the matrix; partial pivoting widens the upper band to at most `kl + ku`). The skipped operations
|
||||
/// only combine entries that are exactly `+0.0` (they stay `+0.0` through the full sweep), so the
|
||||
/// result is bit for bit that of the full sweep (for finite input that does not overflow). A matrix with full bandwidth (the cyclic matrix of
|
||||
/// a closed fit) costs what it did before.
|
||||
///
|
||||
/// A skipped `0 * x` is not zero when `x` is NaN or infinite, so a matrix or right-hand side with
|
||||
/// NaN, infinity or magnitudes above `1e100` (which could overflow) takes the full sweep, and so
|
||||
/// does a back-substitution row whose banded sum is zero (skipped zero terms can flip its sign).
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
#[cfg(test)]
|
||||
if tests::DENSE_ORACLE.get() {
|
||||
return tests::solve_dense(a, b);
|
||||
}
|
||||
let n = b.len();
|
||||
// Per row: how far its non-zero entries reach left and right of the diagonal, and whether one
|
||||
// is NaN, infinite or large enough to overflow.
|
||||
let measure = |r: usize, row: &[f64]| {
|
||||
// Bit test, not `!= 0.0`: a stored `-0.0` is not the `+0.0` the skipped entries rely on.
|
||||
let row = row.get(..n).unwrap_or(row);
|
||||
let Some(first) = row.iter().position(|v| v.to_bits() != 0) else { return (0, 0, false) };
|
||||
let last = row.iter().rposition(|v| v.to_bits() != 0).unwrap_or(first);
|
||||
let wild = row.get(first..=last).is_some_and(|band| band.iter().any(|v| v.is_nan() || v.abs() > 1e100));
|
||||
(r.saturating_sub(first), last.saturating_sub(r), wild)
|
||||
};
|
||||
let join = |x: (usize, usize, bool), m: (usize, usize, bool)| (x.0.max(m.0), x.1.max(m.1), x.2 | m.2);
|
||||
// A last row that reaches column 0 (the cyclic matrix of a closed fit) makes every range of the
|
||||
// sweep full-width whatever the other rows hold, so they need no measuring.
|
||||
let mut m = (0usize, 0usize, false);
|
||||
for r in [0, n.saturating_sub(1)] {
|
||||
if let Some(row) = a.get(r) {
|
||||
m = join(m, measure(r, row));
|
||||
}
|
||||
}
|
||||
// (kl = n - 1 widens every range to the full matrix, so the unmeasured rows cannot matter.)
|
||||
if m.0 + 1 < n {
|
||||
m = a.iter().enumerate().take(n).fold(m, |x, (r, row)| join(x, measure(r, row)));
|
||||
}
|
||||
let (mut kl, mut ku, wild) = m;
|
||||
if wild || b.iter().any(|v| v.is_nan() || v.abs() > 1e100) {
|
||||
(kl, ku) = (n, n);
|
||||
}
|
||||
let kw = ku + kl;
|
||||
for c in 0..n {
|
||||
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
let end = (c + kl).min(n - 1) + 1;
|
||||
let piv = (c..end).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[piv][c].abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, piv);
|
||||
b.swap(c, piv);
|
||||
for r in c + 1..n {
|
||||
let kend = (c + kw).min(n - 1) + 1;
|
||||
for r in c + 1..end {
|
||||
let f = a[r][c] / a[c][c];
|
||||
if f != 0.0 {
|
||||
for k in c..n {
|
||||
for k in c..kend {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
@@ -370,7 +413,12 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for r in (0..n).rev() {
|
||||
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
let end = (r + kw).min(n - 1) + 1;
|
||||
let mut s: f64 = (r + 1..end).map(|k| a[r][k] * x[k]).sum();
|
||||
if s == 0.0 && end < n {
|
||||
// The skipped terms are signed zeros; added to a zero they can flip its sign.
|
||||
s = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
}
|
||||
x[r] = (b[r] - s) / a[r][r];
|
||||
}
|
||||
Some(x)
|
||||
|
||||
@@ -380,3 +380,196 @@ fn a_normal_low_degree_spline_is_valid() {
|
||||
assert!(s.is_valid());
|
||||
assert!(s.tessellate(1e-3).len() > 4);
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While set, `solve` runs `solve_dense` (this thread only, so parallel tests do not mix).
|
||||
pub(super) static DENSE_ORACLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// `solve` before it was limited to the matrix band, kept as the reference.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub(super) fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
for c in 0..n {
|
||||
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[piv][c].abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, piv);
|
||||
b.swap(c, piv);
|
||||
for r in c + 1..n {
|
||||
let f = a[r][c] / a[c][c];
|
||||
if f != 0.0 {
|
||||
for k in c..n {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for r in (0..n).rev() {
|
||||
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
x[r] = (b[r] - s) / a[r][r];
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
fn bits(v: &[f64]) -> Vec<u64> {
|
||||
v.iter().map(|x| x.to_bits()).collect()
|
||||
}
|
||||
|
||||
fn bits2(v: &[Vec2]) -> Vec<u64> {
|
||||
v.iter().flat_map(|p| [p.x.to_bits(), p.y.to_bits()]).collect()
|
||||
}
|
||||
|
||||
/// Both solvers on one system: `None` together, or the same bits.
|
||||
fn assert_same_solution(a: &[Vec<f64>], b: &[f64], what: &str) {
|
||||
let old = solve_dense(a.to_vec(), b.to_vec());
|
||||
let new = solve(a.to_vec(), b.to_vec());
|
||||
assert_eq!(old.as_deref().map(bits), new.as_deref().map(bits), "{what}");
|
||||
}
|
||||
|
||||
/// A random matrix with `kl` sub- and `ku` super-diagonals (some entries exactly zero).
|
||||
fn banded(n: usize, kl: usize, ku: usize, seed: &mut u64) -> Vec<Vec<f64>> {
|
||||
let mut a = vec![vec![0.0; n]; n];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
for (c, v) in row.iter_mut().enumerate() {
|
||||
if c + kl >= r && c <= r + ku && noise(seed) > 0.15 {
|
||||
*v = noise(seed) * 4.0 - 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banded_solve_matches_the_dense_sweep_bit_for_bit() {
|
||||
let mut seed = 0x5eed_u64;
|
||||
for n in [1, 2, 3, 4, 5, 8, 17, 40, 120] {
|
||||
for (kl, ku) in [(0, 0), (1, 0), (0, 2), (1, 1), (3, 3), (2, 5), (n, n)] {
|
||||
for _ in 0..6 {
|
||||
let a = banded(n, kl, ku, &mut seed);
|
||||
// Right-hand sides with zeros of both signs make the zero-sign cases reachable.
|
||||
let b: Vec<f64> = (0..n)
|
||||
.map(|i| [noise(&mut seed) * 9.0 - 4.0, 0.0, -0.0][(noise(&mut seed) * 3.0) as usize % 3] * (i % 4 != 0) as u8 as f64)
|
||||
.collect();
|
||||
assert_same_solution(&a, &b, &format!("n={n} kl={kl} ku={ku}"));
|
||||
assert_same_solution(&a, &vec![0.0; n], &format!("zero rhs n={n} kl={kl} ku={ku}"));
|
||||
assert_same_solution(&a, &vec![-0.0; n], &format!("negative-zero rhs n={n} kl={kl} ku={ku}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_handles_negative_zeros_signed_zero_sums_and_hostile_values() {
|
||||
let mut seed = 77_u64;
|
||||
for n in [3, 6, 30] {
|
||||
let mut a = banded(n, 1, 2, &mut seed);
|
||||
// A stored `-0.0` outside the band is still part of the matrix.
|
||||
a[0][n - 1] = -0.0;
|
||||
a[n - 1][0] = -0.0;
|
||||
let b: Vec<f64> = (0..n).map(|i| if i % 2 == 0 { -0.0 } else { 1.0 }).collect();
|
||||
assert_same_solution(&a, &b, &format!("negative zeros n={n}"));
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e200, 1e101] {
|
||||
let mut ab = a.clone();
|
||||
ab[n / 2][n / 2] = bad;
|
||||
assert_same_solution(&ab, &b, &format!("{bad:?} in the matrix, n={n}"));
|
||||
let mut bb = b.clone();
|
||||
bb[n - 1] = bad;
|
||||
assert_same_solution(&a, &bb, &format!("{bad:?} in the rhs, n={n}"));
|
||||
}
|
||||
}
|
||||
// Bad values in a narrow band with no corner entry, so the guard (not a full-width matrix) decides.
|
||||
for bad in [f64::NAN, f64::INFINITY, 1e200, 1e101] {
|
||||
let a = banded(12, 1, 2, &mut seed);
|
||||
let mut ab = a.clone();
|
||||
ab[5][5] = bad;
|
||||
ab[5][6] = 1.0;
|
||||
assert_same_solution(&ab, &[1.0; 12], &format!("{bad:?} in a narrow band"));
|
||||
let mut b = vec![1.0; 12];
|
||||
b[3] = bad;
|
||||
assert_same_solution(&a, &b, &format!("{bad:?} in the rhs of a narrow band"));
|
||||
}
|
||||
// Singular and empty systems.
|
||||
assert_same_solution(&[vec![0.0, 0.0], vec![0.0, 1.0]], &[1.0, 1.0], "singular");
|
||||
assert_same_solution(&[], &[], "empty");
|
||||
assert_eq!(solve(Vec::new(), Vec::new()), Some(Vec::new()));
|
||||
}
|
||||
|
||||
/// Fit points: a wavy line with noise; every `zero_every`-th coordinate is exactly zero.
|
||||
fn fit_points(n: usize, seed: &mut u64, zero_every: usize) -> Vec<Vec2> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let x = i as f64 * 3.0 + noise(seed);
|
||||
let y = (i as f64 * 0.3).sin() * 50.0 + noise(seed) * 5.0;
|
||||
if zero_every > 0 && i % zero_every == 0 { Vec2::new(x, 0.0) } else { Vec2::new(x, y) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spline_bits(s: &Spline) -> (Vec<u64>, Vec<u64>, Vec<u64>, Vec<u64>, usize) {
|
||||
(bits(&s.knots), bits2(&s.control), bits(&s.weights), bits2(&s.fit), s.degree)
|
||||
}
|
||||
|
||||
/// Fit with the banded `solve`, then with the dense reference, and compare every bit.
|
||||
/// Clears `DENSE_ORACLE` when dropped, so a failing assertion cannot leave it set.
|
||||
struct OracleOff;
|
||||
impl Drop for OracleOff {
|
||||
fn drop(&mut self) {
|
||||
DENSE_ORACLE.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fit_unchanged(fit: &[Vec2], closed: bool, opts: FitOptions, what: &str) {
|
||||
let _off = OracleOff;
|
||||
let new = Spline::fit_with(fit, closed, opts);
|
||||
DENSE_ORACLE.set(true);
|
||||
let old = Spline::fit_with(fit, closed, opts);
|
||||
DENSE_ORACLE.set(false);
|
||||
assert_eq!(spline_bits(&old), spline_bits(&new), "{what}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fits_are_bit_identical_to_the_dense_solver() {
|
||||
let mut seed = 2432_u64;
|
||||
let tolerant = FitOptions { tolerance: 0.5, ..FitOptions::default() };
|
||||
let tangents = FitOptions { start_tangent: Some(Vec2::new(0.0, 1.0)), end_tangent: Some(Vec2::new(1.0, -1.0)), ..FitOptions::default() };
|
||||
let chord = FitOptions { knots: crate::KnotParam::Chord, ..FitOptions::default() };
|
||||
for n in [3, 4, 5, 7, 12, 30, 100] {
|
||||
for round in 0..4 {
|
||||
let zero_every = [0, 2, 3, 1][round];
|
||||
let pts = fit_points(n, &mut seed, zero_every);
|
||||
for closed in [false, true] {
|
||||
for (name, opts) in [("default", FitOptions::default()), ("tolerance", tolerant), ("tangents", tangents), ("chord", chord)] {
|
||||
assert_fit_unchanged(&pts, closed, opts, &format!("n={n} closed={closed} {name} zero_every={zero_every}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Large point sets: open interpolation (banded) and a closed one (full bandwidth), and a
|
||||
// tolerance fit at the size the approximation search still accepts.
|
||||
let big = fit_points(1000, &mut seed, 0);
|
||||
assert_fit_unchanged(&big, false, FitOptions::default(), "n=1000 open");
|
||||
assert_fit_unchanged(&big[..150], true, FitOptions::default(), "n=150 closed");
|
||||
assert_fit_unchanged(&big[..300], false, tolerant, "n=300 tolerance");
|
||||
// Repeated points are kinks, which fit each piece on its own.
|
||||
let mut kinked = fit_points(40, &mut seed, 0);
|
||||
kinked.insert(10, kinked[10]);
|
||||
kinked.insert(25, kinked[25]);
|
||||
assert_fit_unchanged(&kinked, false, FitOptions::default(), "kinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cyclic_matrix_of_a_closed_fit_keeps_the_full_sweep() {
|
||||
// Corner entries give the closed matrix a full bandwidth; it must still agree with the dense sweep.
|
||||
let mut seed = 9_u64;
|
||||
for n in [4, 9, 60] {
|
||||
let mut a = banded(n, 1, 1, &mut seed);
|
||||
a[0][n - 1] = 0.7;
|
||||
a[n - 1][0] = -0.3;
|
||||
let b: Vec<f64> = (0..n).map(|_| noise(&mut seed)).collect();
|
||||
assert_same_solution(&a, &b, &format!("cyclic n={n}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ quick-xml = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
|
||||
acadrust = "0.6.6"
|
||||
tiny-skia = { workspace = true }
|
||||
# Reads plotted PDFs back.
|
||||
craft-pdf = { workspace = true, features = ["read"] }
|
||||
|
||||
# Inspect DWG output only where cadcraft-dwg builds its native bridge. An unconditional
|
||||
# dev-dependency also builds acadrust during --all-targets, breaking FreeBSD aarch64 (#2526).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dev-dependencies]
|
||||
acadrust = "0.6.6"
|
||||
|
||||
+218
-72
@@ -417,10 +417,11 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
}
|
||||
if proxies {
|
||||
f.complete_class_table = classes.iter().filter(|(k, _, _)| k == "CLASS").count() <= MAX_OBJECTS;
|
||||
f.class_order = class_order;
|
||||
}
|
||||
for (kind, _, t) in &classes {
|
||||
if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS {
|
||||
if kind == "CLASS" && (f.complete_class_table || val(t, 1).is_some_and(|n| types.contains(&up(&n)))) && f.classes.len() < MAX_OBJECTS {
|
||||
f.classes.push(t.iter().map(raw).collect());
|
||||
}
|
||||
}
|
||||
@@ -517,44 +518,108 @@ fn retained_objects(
|
||||
// hundreds of thousands of groups) would be read once per child (#862).
|
||||
let mut xdict_memo: HashMap<usize, bool> = HashMap::new();
|
||||
let mut is_xdict = |index: usize| *xdict_memo.entry(index).or_insert_with(|| is_xdict(index));
|
||||
let mut retained = HashMap::new();
|
||||
for index in 0..objects.len() {
|
||||
let mut path = Vec::new();
|
||||
let mut current = index;
|
||||
let keep = loop {
|
||||
if let Some(&known) = retained.get(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(current);
|
||||
let Some((_, _, tags)) = objects.get(current) else { break false };
|
||||
let header = head(tags);
|
||||
let (Some(handle), Some(owner)) = (header.handle, header.owner) else { break false };
|
||||
if by_handle.get(&handle) != Some(¤t) {
|
||||
break false;
|
||||
}
|
||||
if root == Some(owner.as_str()) {
|
||||
break root_key(&handle).is_some_and(|key| !modelled_key(key));
|
||||
}
|
||||
if let Some(&parent) = by_handle.get(&owner) {
|
||||
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
|
||||
let hard = hard_owners(objects.iter().filter_map(|(_, _, t)| Some((head(t).handle?, held(t)))));
|
||||
// One pass with `kept` the objects an earlier pass kept: a null-owner object goes with the
|
||||
// first of its hard owners that is kept.
|
||||
let mut pass = |kept: &HashMap<usize, bool>| {
|
||||
// Previously live objects already have a proved path to a retained owner. Seed that
|
||||
// proof before traversal, so mutual hard owners cannot hide it from their children.
|
||||
let mut retained: HashMap<usize, bool> = kept.iter().filter(|(_, live)| **live).map(|(&i, &live)| (i, live)).collect();
|
||||
let kept = |h: &str| by_handle.get(h).and_then(|i| kept.get(i)) == Some(&true);
|
||||
for index in 0..objects.len() {
|
||||
let mut path = Vec::new();
|
||||
let mut current = index;
|
||||
let keep = loop {
|
||||
if let Some(&known) = retained.get(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
current = parent;
|
||||
continue;
|
||||
path.push(current);
|
||||
let Some((_, _, tags)) = objects.get(current) else { break false };
|
||||
let header = head(tags);
|
||||
let Some(handle) = header.handle else { break false };
|
||||
let Some(owner) = owner_of(header.owner, &handle, &hard, kept) else { break false };
|
||||
if by_handle.get(&handle) != Some(¤t) {
|
||||
break false;
|
||||
}
|
||||
if root == Some(owner.as_str()) {
|
||||
break root_key(&handle).is_some_and(|key| !modelled_key(key));
|
||||
}
|
||||
if let Some(&parent) = by_handle.get(&owner) {
|
||||
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
|
||||
break false;
|
||||
}
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
if is_xdict(current) {
|
||||
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
|
||||
}
|
||||
break entities.get(&owner) == Some(&true);
|
||||
};
|
||||
for visited in path {
|
||||
retained.insert(visited, keep);
|
||||
}
|
||||
if is_xdict(current) {
|
||||
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
|
||||
}
|
||||
retained
|
||||
};
|
||||
settle(&hard, |kept| pass(kept))
|
||||
}
|
||||
|
||||
/// Run `pass` until what it keeps settles. The first pass sees nothing kept, so a null-owner
|
||||
/// object goes with its first hard owner; while one of its other hard owners is kept and that
|
||||
/// one is not, the next pass moves it there. A file where no object has two hard owners takes
|
||||
/// one pass; otherwise it stops when a pass changes nothing, after at most [`MAX_DEPTH`] more.
|
||||
fn settle<K: Eq + std::hash::Hash>(
|
||||
hard: &HashMap<String, Vec<String>>,
|
||||
mut pass: impl FnMut(&HashMap<K, bool>) -> HashMap<K, bool>,
|
||||
) -> HashMap<K, bool> {
|
||||
let mut kept = pass(&HashMap::new());
|
||||
if hard.values().all(|owners| owners.len() < 2) {
|
||||
return kept;
|
||||
}
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let next = pass(&kept);
|
||||
if next == kept {
|
||||
break;
|
||||
}
|
||||
kept = next;
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// The handles an object names by a hard owner reference (group 360).
|
||||
fn held(tags: &[Tag]) -> impl Iterator<Item = String> + '_ {
|
||||
tags.iter().take_while(|g| g.code < 1000).filter(|g| g.code == 360).map(|g| g.str())
|
||||
}
|
||||
|
||||
/// The objects that own each object by a hard owner reference (group 360), in file order:
|
||||
/// `child handle → owner handles`.
|
||||
fn hard_owners<S: AsRef<str>>(objects: impl Iterator<Item = (String, impl Iterator<Item = S>)>) -> HashMap<String, Vec<String>> {
|
||||
// Membership is hashed, while the vector keeps file order. A long list of distinct
|
||||
// holders for one child must not scan all earlier holders once per reference.
|
||||
let mut out: HashMap<String, (Vec<String>, HashSet<String>)> = HashMap::new();
|
||||
for (h, children) in objects {
|
||||
for c in children {
|
||||
let (owners, seen) = out.entry(norm(c.as_ref())).or_default();
|
||||
if seen.insert(h.clone()) {
|
||||
owners.push(h.clone());
|
||||
}
|
||||
break entities.get(&owner) == Some(&true);
|
||||
};
|
||||
for visited in path {
|
||||
retained.insert(visited, keep);
|
||||
}
|
||||
}
|
||||
retained
|
||||
out.into_iter().map(|(child, (owners, _))| (child, owners)).collect()
|
||||
}
|
||||
|
||||
/// An object's owner: its group 330, or for one whose owner is null (another application's
|
||||
/// proxy that another proxy holds) the object that hard-owns it ([`hard_owners`]): the first
|
||||
/// one that is `kept`, or else the first one.
|
||||
fn owner_of(owner: Option<String>, handle: &str, hard: &HashMap<String, Vec<String>>, kept: impl Fn(&str) -> bool) -> Option<String> {
|
||||
owner.filter(|o| o != "0").or_else(|| {
|
||||
let owners = hard.get(handle)?;
|
||||
owners.iter().find(|o| kept(o)).or_else(|| owners.first()).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's
|
||||
@@ -641,6 +706,8 @@ pub(crate) fn unknown_entity(u: &Unknown, e: &Entity, owner: &str, paper: bool)
|
||||
}
|
||||
_ if in_group => continue,
|
||||
5 | 67 => continue,
|
||||
// After a subclass marker, 330 can be proxy data even without a source owner.
|
||||
100 => owner_seen = true,
|
||||
330 if !owner_seen => {
|
||||
owner_seen = true;
|
||||
continue;
|
||||
@@ -858,7 +925,7 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
for (i, n) in recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).chain(appended).enumerate() {
|
||||
numbers.entry(n).or_insert(500_i64.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)));
|
||||
}
|
||||
let renumber = |old: Option<i64>| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied());
|
||||
let renumber = |old| proxy_class_number(f, &numbers, old);
|
||||
// Entities of types CADCraft doesn't model, placed among the written ones.
|
||||
let mut planned = plan_unknown(d, &recs, tags, &roles, modern);
|
||||
for list in planned.values_mut() {
|
||||
@@ -901,43 +968,56 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
let objs: HashMap<String, &ForeignObject> = f.objects.iter().map(|o| (o.handle.hex(), o)).collect();
|
||||
let dict_entries: HashMap<String, &str> = f.dictionary.iter().map(|(k, h)| (h.hex(), k.as_str())).collect();
|
||||
let nod = f.targets.iter().filter(|(_, k)| k.0 == "NOD" && k.1.is_empty()).map(|(h, _)| h.hex()).collect::<HashSet<_>>();
|
||||
let owners: HashMap<String, String> = f
|
||||
let hard = hard_owners(
|
||||
f.objects.iter().map(|o| (o.handle.hex(), o.tags.iter().take_while(|t| t.code < 1000).filter(|t| t.code == 360).map(|t| t.value.trim()))),
|
||||
);
|
||||
// Each kept object's own owner (group 330), read one record at a time.
|
||||
let own: HashMap<String, Option<String>> = f
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::<Vec<_>>()).owner?)))
|
||||
.map(|o| {
|
||||
let t: Vec<Tag> = o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect();
|
||||
(o.handle.hex(), head(&t).owner)
|
||||
})
|
||||
.collect();
|
||||
let mut alive_memo: HashMap<String, bool> = HashMap::new();
|
||||
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none()) {
|
||||
alive_memo.insert(o.handle.hex(), false);
|
||||
}
|
||||
for o in &f.objects {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = o.handle.hex();
|
||||
let verdict = loop {
|
||||
if let Some(&v) = alive_memo.get(&cur) {
|
||||
break v;
|
||||
}
|
||||
if path.contains(&cur) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(cur.clone());
|
||||
let Some(owner) = owners.get(&cur).cloned() else { break false };
|
||||
if objs.contains_key(&owner) {
|
||||
cur = owner;
|
||||
continue;
|
||||
}
|
||||
if nod.contains(&owner) {
|
||||
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
|
||||
}
|
||||
if attached.contains(&cur) {
|
||||
break true;
|
||||
}
|
||||
break unknown.contains(&owner) && written.contains(&owner);
|
||||
};
|
||||
for p in path {
|
||||
alive_memo.insert(p, verdict);
|
||||
let alive_memo = settle(&hard, |kept: &HashMap<String, bool>| {
|
||||
// Keep established live paths available to descendants during this pass as well.
|
||||
let mut alive_memo: HashMap<String, bool> = kept.iter().filter(|(_, live)| **live).map(|(h, &live)| (h.clone(), live)).collect();
|
||||
let kept = |h: &str| kept.get(h) == Some(&true);
|
||||
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none())
|
||||
{
|
||||
alive_memo.insert(o.handle.hex(), false);
|
||||
}
|
||||
}
|
||||
for o in &f.objects {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = o.handle.hex();
|
||||
let verdict = loop {
|
||||
if let Some(&v) = alive_memo.get(&cur) {
|
||||
break v;
|
||||
}
|
||||
if path.contains(&cur) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(cur.clone());
|
||||
let Some(owner) = owner_of(own.get(&cur).cloned().flatten(), &cur, &hard, kept) else { break false };
|
||||
if objs.contains_key(&owner) {
|
||||
cur = owner;
|
||||
continue;
|
||||
}
|
||||
if nod.contains(&owner) {
|
||||
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
|
||||
}
|
||||
if attached.contains(&cur) {
|
||||
break true;
|
||||
}
|
||||
break unknown.contains(&owner) && written.contains(&owner);
|
||||
};
|
||||
for p in path {
|
||||
alive_memo.insert(p, verdict);
|
||||
}
|
||||
}
|
||||
alive_memo
|
||||
});
|
||||
// New handles for the written kept objects, above every written handle and the seed.
|
||||
let mut next = seed(tags).max(max_handle(tags).saturating_add(1));
|
||||
let mut alive: HashMap<String, String> = HashMap::new();
|
||||
@@ -954,11 +1034,8 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
if r.kind == "BLOCK" {
|
||||
block = up(&val(b, 2).unwrap_or_default());
|
||||
}
|
||||
if r.kind == "ENDSEC" && r.sec == "CLASSES" {
|
||||
for c in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(c.iter().map(tag));
|
||||
}
|
||||
if source_class_record(f, r, &recs, tags, &class_names, &mut out) {
|
||||
continue;
|
||||
}
|
||||
if r.kind == "ENDSEC" && r.sec == "OBJECTS" {
|
||||
for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) {
|
||||
@@ -1001,6 +1078,50 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
*tags = out;
|
||||
}
|
||||
|
||||
/// Complete source tables keep positional identities even when names repeat.
|
||||
fn proxy_class_number(f: &Foreign, numbers: &HashMap<String, i64>, old: Option<i64>) -> Option<i64> {
|
||||
if f.complete_class_table {
|
||||
class_index(old).filter(|&i| i < f.classes.len()).and(old)
|
||||
} else {
|
||||
class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// Source classes precede decoder-owned classes so proxy positions stay stable.
|
||||
fn source_class_record(f: &Foreign, record: &Rec, recs: &[Rec], tags: &[Tag], class_names: &HashSet<String>, out: &mut Vec<Tag>) -> bool {
|
||||
if record.sec != "CLASSES" {
|
||||
return false;
|
||||
}
|
||||
if !f.complete_class_table {
|
||||
if record.kind == "ENDSEC" {
|
||||
for class in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(class.iter().map(tag));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if record.kind == "CLASS" {
|
||||
return true;
|
||||
}
|
||||
if record.kind != "ENDSEC" {
|
||||
return false;
|
||||
}
|
||||
for class in &f.classes {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(class.iter().map(tag));
|
||||
}
|
||||
let source_names: HashSet<_> = f.classes.iter().filter_map(|c| c.iter().find(|t| t.code == 1)).map(|t| up(&t.value)).collect();
|
||||
for class in recs.iter().filter(|r| r.sec == "CLASSES" && r.kind == "CLASS") {
|
||||
let body = tags.get(class.start + 1..class.end).unwrap_or_default();
|
||||
if val(body, 1).is_some_and(|n| !source_names.contains(&up(&n))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Where the entities of types CADCraft doesn't model go: each before the next written entity of
|
||||
/// its space or block (else at the end of it), as `(section, record)` lists by the index of the
|
||||
/// record they precede. Owners come from the written block records.
|
||||
@@ -1260,3 +1381,28 @@ fn seed(tags: &[Tag]) -> u64 {
|
||||
fn max_handle(tags: &[Tag]) -> u64 {
|
||||
tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hard_owner_tests {
|
||||
use super::hard_owners;
|
||||
|
||||
#[test]
|
||||
fn holders_are_unique_in_first_seen_order() {
|
||||
let objects =
|
||||
[("10".to_string(), ["00a", "A"].into_iter()), ("20".to_string(), ["A", "00A"].into_iter()), ("10".to_string(), ["A", "A"].into_iter())];
|
||||
let owners = hard_owners(objects.into_iter());
|
||||
assert_eq!(owners.get("A").unwrap(), &["10", "20"]);
|
||||
assert_eq!(owners.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_holders_keep_file_order() {
|
||||
let count = 10_000;
|
||||
let owners = hard_owners((0..count).map(|i| (format!("{i:X}"), ["FFFF", "FFFF"].into_iter())));
|
||||
let holders = owners.get("FFFF").unwrap();
|
||||
assert_eq!(holders.len(), count);
|
||||
for (i, holder) in holders.iter().enumerate() {
|
||||
assert_eq!(holder, &format!("{i:X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,17 @@ pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
|
||||
cadcraft_dxf::write_ascii(&tags)
|
||||
}
|
||||
|
||||
/// Subclass markers the DWG bridge knows by another spelling (it matches them exactly):
|
||||
/// AutoCAD writes a dynamic block lookup parameter's as `AcDbBlockLookUpParameter`, the bridge
|
||||
/// reads `AcDbBlockLookupParameter`; otherwise the parameter loses its name and its lookup
|
||||
/// action in the DWG, and AutoCAD's lookups stop working.
|
||||
/// Proxy type names and lookup-parameter subclass names must match the bridge reader's spelling.
|
||||
/// Ordinary DXF output retains source spellings.
|
||||
fn bridge_markers(tags: &mut [Tag]) {
|
||||
for tag in tags.iter_mut().filter(|tag| tag.code == 0) {
|
||||
for name in ["ACAD_PROXY_ENTITY", "ACAD_PROXY_OBJECT"] {
|
||||
if tag.str().trim().eq_ignore_ascii_case(name) {
|
||||
*tag = Tag::s(0, name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in tags.iter_mut().filter(|t| t.code == 100) {
|
||||
if t.str().trim().eq_ignore_ascii_case("AcDbBlockLookupParameter") {
|
||||
*t = Tag::s(100, "AcDbBlockLookupParameter");
|
||||
|
||||
@@ -3511,7 +3511,7 @@ fn proxies_keep_their_class_when_classes_are_rewritten() {
|
||||
/// classes there: a 2018 drawing's piping proxies came out as LARGE_RADIAL_DIMENSION and
|
||||
/// SECTIONOBJECT, and the ODA File Converter refused the file ("AcDbProxyObject can't be cast to
|
||||
/// AcDbBlockTableRecord"). A DWG before 2013 leaves them out (acadrust writes no class for them).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn proxies_keep_their_class_in_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
@@ -3553,7 +3553,7 @@ fn proxies_keep_their_class_in_dwg() {
|
||||
/// its CLASSES ([`proxy_file`]'s "Stray", 537): kept with that number it would name some other
|
||||
/// class of the DWG, so the DWG leaves it out, as CADCraft's own save does, and keeps the proxies
|
||||
/// whose classes it can name.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
@@ -3602,7 +3602,7 @@ fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
|
||||
/// numbers can't be mapped to the DWG's classes. Kept as they were, they named other classes
|
||||
/// (the ODA refuses such a file), so the DWG leaves them out, as a pre-2013 DWG does, and the
|
||||
/// rest of the drawing is written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! the one written as `*Paper_Space` (the DWG header's paper space block), the others
|
||||
//! `*Paper_SpaceN`. Saving used to make the first tab the active one again.
|
||||
|
||||
// The DWG bridge is unavailable on wasm and FreeBSD ARM64 (#2526); DXF still works.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layout, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use cadcraft_io::Version;
|
||||
use cadcraft_io::{SaveOptions, read, write_with};
|
||||
|
||||
fn three_sheets() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
@@ -19,6 +23,7 @@ fn three_sheets() -> Drawing {
|
||||
}
|
||||
|
||||
/// The layout the DWG marks active: the one whose block the header names as paper space.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn dwg_active_layout(dwg: &[u8]) -> String {
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.to_vec())).read().unwrap();
|
||||
let ps = doc.header.paper_space_block_handle;
|
||||
@@ -31,11 +36,13 @@ fn dwg_active_layout(dwg: &[u8]) -> String {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn dwg(d: &Drawing) -> Vec<u8> {
|
||||
write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn the_active_layout_stays_active_through_dwg_saves() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("S2".into());
|
||||
@@ -68,6 +75,7 @@ fn the_active_layout_survives_dxf_too() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn without_an_active_layout_the_first_tab_is_active() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("gone".into());
|
||||
@@ -75,6 +83,7 @@ fn without_an_active_layout_the_first_tab_is_active() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn the_active_layout_name_is_matched_ignoring_case() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("s2".into());
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! the same DWG version (an older DWG can't hold everything: a 2004 field has no value flags or
|
||||
//! format), read back; CADCraft's DWG must hold the same objects. When the DWG is the DXF's own
|
||||
//! version, that reference is also checked against the DXF as read.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! The DWG is written here with acadrust, giving the second 3D-polyline vertex the highest
|
||||
//! handle: exactly what an editing program produces when it inserts a vertex.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use acadrust::entities::{EntityType, Polyline3D, Vertex3DPolyline};
|
||||
use acadrust::types::{DxfVersion, Handle, Vector3};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an
|
||||
//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables,
|
||||
//! when the DXF has none.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! TABLE heights inherit from the table style unless the file records a cell override.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
// FreeBSD ARM64 has no native DWG bridge (#2526), but the DXF regression still applies.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Space, Table, TableCell, TableStyle};
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
@@ -42,6 +44,7 @@ fn check(d: &Drawing) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn table_cell_heights_keep_inheritance_through_dwg() {
|
||||
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
|
||||
// A style record can be present for metadata without overriding its text height.
|
||||
@@ -68,11 +71,13 @@ fn table_cell_heights_keep_inheritance_through_dxf() {
|
||||
|
||||
// ---- cells with their own style record (review on #2588) ----
|
||||
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
use acadrust::entities::table::{CellContent, CellStyle, CellStylePropertyFlags};
|
||||
|
||||
/// Write the drawing as a DWG, edit its first cell through acadrust (`edit`), write it again as a
|
||||
/// DWG (or, with `as_dxf`, as a DXF by acadrust's writer) and read it with CADCraft. Returns the first cell's
|
||||
/// text height override.
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::table::TableCell)) -> Option<f64> {
|
||||
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
|
||||
let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap();
|
||||
@@ -100,6 +105,7 @@ fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::ta
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
|
||||
let mut s = CellStyle::new();
|
||||
s.text_height = height;
|
||||
@@ -108,12 +114,14 @@ fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_cell_style_with_the_text_height_flag_keeps_its_height() {
|
||||
let h = first_cell_height(false, |c| c.style = Some(styled(0.5, CellStylePropertyFlags::TEXT_HEIGHT)));
|
||||
assert_eq!(h, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn content_with_format_override_bit_128_keeps_its_own_height() {
|
||||
// acadrust writes the content's height as group 144 and the style's as 140; they differ here
|
||||
// so the result shows which one CADCraft honoured.
|
||||
@@ -131,6 +139,7 @@ fn content_with_format_override_bit_128_keeps_its_own_height() {
|
||||
/// Only the first content's override stands for the cell: a later content's height must not
|
||||
/// replace the first content's inherited one (review on #2588).
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_later_contents_height_does_not_replace_the_first_contents() {
|
||||
let h = first_cell_height(false, |c| {
|
||||
c.style = Some(styled(0.18, CellStylePropertyFlags::NONE));
|
||||
@@ -147,6 +156,7 @@ fn a_later_contents_height_does_not_replace_the_first_contents() {
|
||||
/// Bit 128 with an unusable content height copies nothing, so the writer's default style height
|
||||
/// is still cleared (review on #2588).
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn bit_128_with_an_invalid_height_still_clears_the_default() {
|
||||
for bad in [0.0, -1.0, f64::NAN] {
|
||||
let h = first_cell_height(false, |c| {
|
||||
@@ -162,12 +172,14 @@ fn bit_128_with_an_invalid_height_still_clears_the_default() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_cell_with_no_style_record_inherits_the_table_style() {
|
||||
assert_eq!(first_cell_height(false, |c| c.style = None), None);
|
||||
}
|
||||
|
||||
/// A style record with a height and neither override is not an override, through DWG.
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_style_height_without_an_override_is_cleared() {
|
||||
assert_eq!(first_cell_height(false, |c| c.style = Some(styled(0.18, CellStylePropertyFlags::NONE))), None);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The targets without the native DWG bridge still handle DXF and report an actionable error
|
||||
//! for DWG instead of pretending that opening or saving succeeded.
|
||||
|
||||
#![cfg(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use cadcraft_io::{read, write};
|
||||
|
||||
#[test]
|
||||
fn no_dwg_bridge_preserves_dxf_and_reports_dwg_unavailable() {
|
||||
let drawing = Drawing::new_metric();
|
||||
let dxf = write(&drawing, "drawing.dxf").expect("DXF remains available");
|
||||
assert!(read(&dxf, "drawing.dxf").is_ok());
|
||||
|
||||
let read_error = read(b"AC1032unavailable", "drawing.dwg").unwrap_err().to_string();
|
||||
let write_error = write(&drawing, "drawing.dwg").unwrap_err().to_string();
|
||||
for error in [read_error, write_error] {
|
||||
assert!(error.contains("DWG"), "{error}");
|
||||
assert!(error.contains("DXF"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! An object of another application whose owner is null, but which another kept object holds by a
|
||||
//! hard owner reference (360), is kept with it, as one application's proxy object held by another.
|
||||
//! In DXF and in DWG, save after save.
|
||||
|
||||
use cadcraft_io::{read, read_dxf, write, write_dxf};
|
||||
|
||||
/// A named object dictionary entry `OTHER_APP` (a dictionary) with an XRECORD that holds a second
|
||||
/// XRECORD by a 360 reference; the second one's owner is null. An object nothing keeps names the
|
||||
/// second XRECORD by a 360 first.
|
||||
fn source() -> String {
|
||||
let groups: &[(i32, &str)] = &[
|
||||
(0, "SECTION"),
|
||||
(2, "ENTITIES"),
|
||||
(0, "LINE"),
|
||||
(5, "30"),
|
||||
(8, "0"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "OBJECTS"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "C"),
|
||||
(330, "0"),
|
||||
(100, "AcDbDictionary"),
|
||||
(3, "OTHER_APP"),
|
||||
(350, "50"),
|
||||
// An object nothing keeps (null owner, no holder) that also names the held record by a
|
||||
// 360, before the holder does: it is dropped, and the held record stays with the holder.
|
||||
(0, "XRECORD"),
|
||||
(5, "60"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "stray"),
|
||||
(360, "52"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "50"),
|
||||
(330, "C"),
|
||||
(100, "AcDbDictionary"),
|
||||
(281, "1"),
|
||||
(3, "HOLDER"),
|
||||
(360, "51"),
|
||||
(0, "XRECORD"),
|
||||
(5, "51"),
|
||||
(102, "{ACAD_REACTORS"),
|
||||
(330, "50"),
|
||||
(102, "}"),
|
||||
(330, "50"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "holder"),
|
||||
(360, "52"),
|
||||
(0, "XRECORD"),
|
||||
(5, "52"),
|
||||
(102, "{ACAD_REACTORS"),
|
||||
(330, "51"),
|
||||
(102, "}"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "held"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect()
|
||||
}
|
||||
|
||||
fn check(text: &str, label: &str) {
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter_map(|c| Some((*c.first()?, *c.get(1)?))).collect();
|
||||
// Each record from its `0` group on, as (code, value) pairs.
|
||||
let records: Vec<&[(&str, &str)]> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (c, _))| *c == "0")
|
||||
.map(|(i, _)| {
|
||||
let n = pairs[i + 1..].iter().position(|(c, _)| *c == "0").map_or(pairs.len(), |n| i + 1 + n);
|
||||
&pairs[i..n]
|
||||
})
|
||||
.collect();
|
||||
let with = |text: &str| records.iter().find(|r| r.contains(&("1", text))).copied().unwrap_or_else(|| panic!("{label}: no record {text}"));
|
||||
let holder = with("holder");
|
||||
let Some((_, held_handle)) = holder.iter().find(|(c, _)| *c == "360") else { panic!("{label}: the holder holds nothing: {holder:?}") };
|
||||
let held = with("held");
|
||||
assert!(held.contains(&("5", *held_handle)), "{label}: the holder's 360 names the held record: {holder:?} {held:?}");
|
||||
assert!(!records.iter().any(|r| r.contains(&("1", "stray"))), "{label}: the object nothing keeps is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_held_by_a_kept_one_is_kept() {
|
||||
let mut d = read_dxf(source().as_bytes()).expect("read");
|
||||
for generation in 1..=2 {
|
||||
let text = write_dxf(&d);
|
||||
check(&text, &format!("DXF {generation}"));
|
||||
d = read_dxf(text.as_bytes()).expect("reread");
|
||||
}
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
for generation in 1..=2 {
|
||||
let dwg = write(&d, "held.dwg").expect("DWG");
|
||||
d = read(&dwg, "held.dwg").expect("DWG read");
|
||||
check(&write_dxf(&d), &format!("DWG {generation}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutual hard owners must not erase the live path through a retained dictionary entry.
|
||||
#[test]
|
||||
fn cyclic_hard_owners_with_a_live_holder_stay_kept() {
|
||||
let groups = [
|
||||
(0, "SECTION"),
|
||||
(2, "OBJECTS"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "C"),
|
||||
(330, "0"),
|
||||
(100, "AcDbDictionary"),
|
||||
(3, "OTHER_APP"),
|
||||
(350, "50"),
|
||||
(0, "XRECORD"),
|
||||
(5, "60"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "stray"),
|
||||
(360, "54"),
|
||||
(0, "XRECORD"),
|
||||
(5, "52"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "first"),
|
||||
(360, "53"),
|
||||
(360, "54"),
|
||||
(0, "XRECORD"),
|
||||
(5, "53"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "second"),
|
||||
(360, "52"),
|
||||
(0, "XRECORD"),
|
||||
(5, "54"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "child"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "50"),
|
||||
(330, "C"),
|
||||
(100, "AcDbDictionary"),
|
||||
(281, "1"),
|
||||
(3, "HOLDER"),
|
||||
(360, "51"),
|
||||
(0, "XRECORD"),
|
||||
(5, "51"),
|
||||
(330, "50"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "holder"),
|
||||
(360, "52"),
|
||||
(360, "53"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
let text = groups.iter().map(|(code, value)| format!("{code}\n{value}\n")).collect::<String>();
|
||||
let mut drawing = read_dxf(text.as_bytes()).unwrap();
|
||||
let mut names = vec!["copy.dxf", "again.dxf"];
|
||||
if cfg!(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))) {
|
||||
names.extend(["copy.dwg", "again.dwg"]);
|
||||
}
|
||||
for name in names {
|
||||
let bytes = write(&drawing, name).unwrap();
|
||||
drawing = read(&bytes, name).unwrap();
|
||||
let text = write_dxf(&drawing);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = cadcraft_dxf::records(&objects.tags);
|
||||
let record = |label: &str| records.iter().find(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == label)).unwrap().1.clone();
|
||||
let first = record("first");
|
||||
let second = record("second");
|
||||
let holder = record("holder");
|
||||
let child = record("child");
|
||||
assert!(!records.iter().any(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == "stray")), "{name}");
|
||||
let handle = |tags: &[cadcraft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&second)), "{name}");
|
||||
assert!(second.iter().any(|t| t.code == 360 && t.str() == handle(&first)), "{name}");
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&child)), "{name}");
|
||||
for object in [&first, &second] {
|
||||
assert!(holder.iter().any(|t| t.code == 360 && t.str() == handle(object)), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real 330 owner takes precedence, even when a retained object also names the record by 360.
|
||||
#[test]
|
||||
fn a_real_owner_is_not_replaced_by_a_hard_owner() {
|
||||
let text = source().replace("330\n0\n100\nAcDbXrecord\n280\n1\n1\nheld", "330\nDEAD\n100\nAcDbXrecord\n280\n1\n1\nheld");
|
||||
assert!(text.contains("330\nDEAD"));
|
||||
let drawing = read_dxf(text.as_bytes()).unwrap();
|
||||
let output = write_dxf(&drawing);
|
||||
assert!(output.lines().any(|line| line.trim() == "holder"));
|
||||
assert!(!output.lines().any(|line| line.trim() == "held"));
|
||||
}
|
||||
@@ -103,7 +103,7 @@ fn dxf_angular_block_text_keeps_the_decimal_separator() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn dwg_angular_block_text_keeps_the_decimal_separator() {
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! by their own block (entity mode 0 and its handle). Written as "paper space" (mode 1), AutoCAD
|
||||
//! put them on the first layout and left theirs empty.
|
||||
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Layout, Line, Space};
|
||||
|
||||
@@ -87,6 +87,7 @@ fn a_saved_drawing_keeps_the_dynamic_block_as_read() {
|
||||
}
|
||||
|
||||
/// The Door fixture with a scale action on the Width parameter: graph node 6, object 68.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn with_scale_action() -> String {
|
||||
let node = "91 6\n93 32\n95 7\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n";
|
||||
let action = "0 BLOCKSCALEACTION\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n\
|
||||
@@ -105,6 +106,7 @@ fn with_scale_action() -> String {
|
||||
/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused
|
||||
/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's
|
||||
/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte).
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_scale_action_survives_a_dwg_save() {
|
||||
use acadrust::objects::{DynamicBlockData, DynamicBlockObject, ObjectType};
|
||||
|
||||
@@ -74,7 +74,7 @@ fn dxf_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
let (original, expected) = drawing()?;
|
||||
@@ -103,7 +103,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
|
||||
let (d, _) = drawing()?;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
mod cases;
|
||||
mod collections;
|
||||
mod compare;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
mod dwg;
|
||||
mod dxf;
|
||||
mod handle_limits;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Synthetic application data must keep its identity as well as its bytes.
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use acadrust::classes::DxfClass;
|
||||
use acadrust::entities::{EntityCommon, EntityType, ExtendedEntity, ExtendedEntityData, Line, RegisteredClassEntityData};
|
||||
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
|
||||
use acadrust::types::{DxfVersion, Vector3};
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn source(version: DxfVersion) -> Vec<u8> {
|
||||
source_payload(version, ProxyPayload::from_bits(&[0xA5, 0x80], 9))
|
||||
}
|
||||
|
||||
fn source_payload(version: DxfVersion, payload: ProxyPayload) -> Vec<u8> {
|
||||
let mut document = acadrust::CadDocument::new();
|
||||
document.version = version;
|
||||
document.classes.add_or_update(DxfClass::new_entity("SPDSNOTEPOSITION", "mcsDbObjectNotePosition"));
|
||||
let target = document.add_entity(EntityType::Line(Line::from_points(Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0)))).unwrap();
|
||||
document
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon { graphic_data: Some(vec![0x12, 0x34, 0x56]), ..Default::default() },
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SPDSNOTEPOSITION".into(),
|
||||
cpp_class_name: "mcsDbObjectNotePosition".into(),
|
||||
properties: vec![],
|
||||
payload,
|
||||
object_ids: vec![ProxyObjectReference { kind: ProxyReferenceKind::HardPointer, handle: target }],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
acadrust::DwgWriter::write_to_vec(&document).unwrap()
|
||||
}
|
||||
|
||||
fn check(bytes: &[u8], name: &str) -> cadcraft_doc::Drawing {
|
||||
let drawing = cadcraft_io::read(bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 2);
|
||||
let line = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle;
|
||||
let unknown = drawing
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Unknown(u) => Some(u),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(unknown.tags.iter().any(|t| t.code == 340 && t.value == line.hex()));
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let classes = sections.iter().find(|s| s.name == "CLASSES").unwrap();
|
||||
let classes = cadcraft_dxf::records(&classes.tags);
|
||||
let decoded = acadrust::DxfReader::from_reader(Cursor::new(dxf)).unwrap().read().unwrap();
|
||||
let proxy = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(proxy.payload.bit_count, 9);
|
||||
assert_eq!(proxy.payload.data(), [0xA5, 0x80]);
|
||||
assert_eq!(proxy.graphics.data(), [0x12, 0x34, 0x56]);
|
||||
let class = classes.get(usize::try_from(proxy.class_id - 500).unwrap()).unwrap();
|
||||
assert!(class.1.iter().any(|t| t.code == 1 && t.str() == "SPDSNOTEPOSITION"));
|
||||
assert!(class.1.iter().any(|t| t.code == 2 && t.str() == "mcsDbObjectNotePosition"));
|
||||
drawing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_registered_entities_survive_repeated_saves_with_their_original_class() {
|
||||
for source_version in [DxfVersion::AC1015, DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let input = source(source_version);
|
||||
let initial = check(&input, "source.dwg");
|
||||
for target in [Version::R2013, Version::R2018] {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let mut drawing = initial.clone();
|
||||
for _ in 0..3 {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(target), ..Default::default() }).unwrap();
|
||||
drawing = check(&bytes, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_dwg_leaves_proxies_out_while_legacy_dxf_retains_the_data() {
|
||||
let drawing = check(&source(DxfVersion::AC1032), "source.dwg");
|
||||
for target in [Version::R2000, Version::R2004, Version::R2007, Version::R2010] {
|
||||
let options = SaveOptions { version: Some(target), ..Default::default() };
|
||||
// A DWG before 2013 can't name the proxy's class: it is left out, and the rest is saved.
|
||||
let bytes = cadcraft_io::write_with(&drawing, "old.dwg", &options).unwrap();
|
||||
let reread = cadcraft_io::read(&bytes, "old.dwg").unwrap();
|
||||
assert_eq!(reread.entity_count(), 1, "{target:?}");
|
||||
assert!(reread.model.iter().all(|e| matches!(e.kind, EntityKind::Line(_))), "{target:?}");
|
||||
let bytes = cadcraft_io::write_with(&drawing, "old.dxf", &options).unwrap();
|
||||
check(&bytes, "old.dxf");
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_class_dxf() -> Vec<u8> {
|
||||
let mut text = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n");
|
||||
for (cpp, entity, flags) in [("First", 1, 1025), ("Second", 1, 128), ("Object", 0, 17)] {
|
||||
text.push_str(&format!("0\nCLASS\n1\nCUSTOM\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n1\n280\n1\n281\n{entity}\n"));
|
||||
}
|
||||
text.push_str("0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n0\n20\n0\n11\n10\n21\n0\n");
|
||||
for (handle, class) in [("20", 500), ("21", 501)] {
|
||||
text.push_str(&format!("0\nACAD_PROXY_ENTITY\n5\n{handle}\n100\nAcDbEntity\n8\n0\n100\nAcDbProxyEntity\n90\n498\n91\n{class}\n92\n0\n93\n9\n310\nA580\n330\n10\n340\n10\n350\n10\n360\n10\n94\n0\n95\n33\n70\n0\n"));
|
||||
}
|
||||
text.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n281\n1\n3\nCUSTOM_OBJECT\n350\n30\n0\nACAD_PROXY_OBJECT\n5\n30\n330\nC\n100\nAcDbProxyObject\n90\n499\n91\n502\n93\n9\n310\nA580\n340\n10\n94\n0\n95\n33\n70\n0\n0\nENDSEC\n0\nEOF\n");
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
|
||||
let source = duplicate_class_dxf();
|
||||
for binary in [false, true] {
|
||||
let input = if binary { cadcraft_dxf::write_binary(&cadcraft_dxf::parse(&source).unwrap(), true) } else { source.clone() };
|
||||
let mut drawing = cadcraft_io::read(&input, "source.dxf").unwrap();
|
||||
for _ in 0..3 {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let classes = cadcraft_dxf::records(§ions.iter().find(|s| s.name == "CLASSES").unwrap().tags);
|
||||
let custom: Vec<_> = classes.iter().filter(|(_, t)| t.iter().any(|t| t.code == 1 && t.str() == "CUSTOM")).collect();
|
||||
assert_eq!(custom.len(), 3);
|
||||
for (class, (cpp, flags)) in custom.iter().zip([("First", 1025), ("Second", 128), ("Object", 17)]) {
|
||||
assert!(class.1.iter().any(|t| t.code == 2 && t.str() == cpp));
|
||||
assert!(class.1.iter().any(|t| t.code == 90 && t.i64() == flags));
|
||||
assert!(class.1.iter().any(|t| t.code == 3 && t.str() == "Synthetic"));
|
||||
assert!(class.1.iter().any(|t| t.code == 280 && t.i64() == 1));
|
||||
}
|
||||
let records: Vec<_> =
|
||||
sections.iter().flat_map(|s| cadcraft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
assert_eq!(records.len(), 3);
|
||||
let mut identities = Vec::new();
|
||||
for (_, tags) in &records {
|
||||
let id = tags.iter().find(|t| t.code == 91).unwrap().i64();
|
||||
identities.push(classes[usize::try_from(id - 500).unwrap()].1.iter().find(|t| t.code == 2).unwrap().str());
|
||||
assert!(tags.iter().any(|t| matches!(t.code, 93 | 161) && t.i64() == 9), "{name}: {tags:?}");
|
||||
assert!(tags.iter().any(|t| t.code == 310 && t.str() == "A580"));
|
||||
}
|
||||
identities.sort();
|
||||
assert_eq!(identities, ["First", "Object", "Second"]);
|
||||
drawing = cadcraft_io::read(&bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
let line = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle.hex();
|
||||
for entity in drawing.model.iter().filter_map(|e| if let EntityKind::Unknown(u) = &e.kind { Some(u) } else { None }) {
|
||||
for code in [330, 340, 350, 360] {
|
||||
assert!(entity.tags.iter().any(|t| t.code == code && t.value == line), "missing reference {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_bit_opaque_entities_are_still_retained() {
|
||||
let source = source_payload(DxfVersion::AC1032, ProxyPayload::default());
|
||||
let mut drawing = cadcraft_io::read(&source, "source.dwg").unwrap();
|
||||
for name in ["copy.dxf", "copy.dwg", "again.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
drawing = cadcraft_io::read(&bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 2);
|
||||
assert!(drawing.model.iter().any(|e| matches!(e.kind, EntityKind::Unknown(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_inside_block_definitions_survive_repeated_saves() {
|
||||
use cadcraft_doc::{Block, Common, Entity};
|
||||
let mut drawing = check(&source(DxfVersion::AC1032), "source.dwg");
|
||||
let kind = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Unknown(_))).unwrap().kind.clone();
|
||||
let handle = drawing.new_handle();
|
||||
let mut block = Block::new("CUSTOM_BLOCK");
|
||||
block.entities.push(Entity { handle, common: Common::default(), kind });
|
||||
drawing.blocks.insert("CUSTOM_BLOCK".into(), std::sync::Arc::new(block));
|
||||
for name in ["copy.dxf", "copy.dwg", "again.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
drawing = check(&bytes, name);
|
||||
let block = drawing.blocks.get("CUSTOM_BLOCK").unwrap();
|
||||
assert_eq!(block.entities.len(), 1);
|
||||
assert!(block.entities.iter().any(|e| matches!(e.kind, EntityKind::Unknown(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_malformed_classes_do_not_block_plain_geometry() {
|
||||
for (name, cpp, flags, count) in [("", "Unused", 0, 0), ("UNUSED", "", 0, 0), ("UNUSED", "Unused", 65536, 0), ("UNUSED", "Unused", 0, i64::MAX)] {
|
||||
let dxf = format!(
|
||||
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n{name}\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n{count}\n281\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n2\n20\n3\n11\n12\n21\n13\n0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let bytes = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
|
||||
let drawing = cadcraft_io::read(&bytes, "plain.dwg").unwrap();
|
||||
let lines: Vec<_> = drawing.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l) } else { None }).collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].a.x, 2.0);
|
||||
assert_eq!(lines[0].a.y, 3.0);
|
||||
assert_eq!(lines[0].b.x, 12.0);
|
||||
assert_eq!(lines[0].b.y, 13.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
|
||||
let source = String::from_utf8(duplicate_class_dxf()).unwrap();
|
||||
for (entity, object) in [("acad_proxy_entity", "acad_proxy_object"), ("AcAd_PrOxY_EnTiTy", "AcAd_PrOxY_ObJeCt")] {
|
||||
let dxf = source.replace("ACAD_PROXY_ENTITY", entity).replace("ACAD_PROXY_OBJECT", object);
|
||||
let drawing = cadcraft_io::read(dxf.as_bytes(), "source.dxf").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
let out = cadcraft_io::write_with(&drawing, "copy.dxf", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&out).unwrap();
|
||||
assert!(tags.iter().any(|t| t.code == 0 && t.str() == entity));
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = cadcraft_dwg::dwg_to_dxf(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&decoded).unwrap();
|
||||
let proxies: Vec<_> = cadcraft_dxf::sections(&tags)
|
||||
.iter()
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind.starts_with("ACAD_PROXY_"))
|
||||
.collect();
|
||||
assert_eq!(proxies.len(), 3);
|
||||
for (_, tags) in proxies {
|
||||
assert!(tags.iter().any(|t| t.code == 310 && t.str() == "A580"));
|
||||
assert!(tags.iter().any(|t| matches!(t.code, 93 | 161) && t.i64() == 9));
|
||||
}
|
||||
let recovered = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(recovered.entity_count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the actual ASCII bridge, which writes Unicode as CIF escapes.
|
||||
#[test]
|
||||
fn unicode_class_identity_survives_the_ascii_bridge() {
|
||||
let mut tags = cadcraft_dxf::parse(&duplicate_class_dxf()).unwrap();
|
||||
for tag in &mut tags {
|
||||
let text = tag.str();
|
||||
let replacement = match (tag.code, text.as_str()) {
|
||||
(1, "CUSTOM") => Some("Объект"),
|
||||
(2, "First") => Some("Первый"),
|
||||
(2, "Second") => Some("Второй"),
|
||||
(2, "Object") => Some("ОбъектКласс"),
|
||||
(3, "Synthetic") => Some("Café🙂"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = replacement {
|
||||
*tag = cadcraft_dxf::Tag::s(tag.code, text);
|
||||
}
|
||||
}
|
||||
let text = cadcraft_dxf::write_ascii(&tags);
|
||||
assert!(text.contains("\\U+"));
|
||||
let mut drawing = cadcraft_io::read(text.as_bytes(), "unicode.dxf").unwrap();
|
||||
for _ in 0..2 {
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read().unwrap();
|
||||
let classes: Vec<_> = decoded.classes.iter().filter(|c| c.dxf_name == "Объект").collect();
|
||||
assert_eq!(classes.len(), 3);
|
||||
for (class, cpp) in classes.iter().zip(["Первый", "Второй", "ОбъектКласс"]) {
|
||||
assert_eq!(class.cpp_class_name, cpp);
|
||||
assert_eq!(class.application_name, "Café🙂");
|
||||
}
|
||||
drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed unused class must not silently collapse two otherwise valid class identities.
|
||||
#[test]
|
||||
fn ambiguous_fallback_classes_are_reported_without_losing_plain_geometry() {
|
||||
for escaped in [false, true] {
|
||||
let mut source = String::from_utf8(duplicate_class_dxf()).unwrap().replacen(
|
||||
"0\nENDSEC\n0\nSECTION\n2\nENTITIES",
|
||||
"0\nCLASS\n1\nUNUSED\n2\nUnused\n90\n65536\n0\nENDSEC\n0\nSECTION\n2\nENTITIES",
|
||||
1,
|
||||
);
|
||||
if escaped {
|
||||
// Different DXF spellings decode to the same name. The unrelated object class
|
||||
// remains unambiguous and must still be written.
|
||||
source = source.replace("1\nCUSTOM\n2\nSecond", "1\n\\U+0043USTOM\n2\nSecond");
|
||||
source = source.replace("1\nCUSTOM\n2\nObject", "1\nOTHER\n2\nObject");
|
||||
}
|
||||
let (bytes, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_ENTITY"), Some(&2));
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_OBJECT"), if escaped { None } else { Some(&1) });
|
||||
let drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 1);
|
||||
assert!(drawing.model.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,18 @@ pub struct AliasDlgState {
|
||||
pub filter: String,
|
||||
pub form: Option<Form>,
|
||||
pub error: Option<String>,
|
||||
/// The warnings the last imported file reported (skipped lines, external commands, unknown
|
||||
/// commands); the first few are shown in the dialog and all of them on the command line.
|
||||
pub warnings: Vec<String>,
|
||||
/// ALIASEDIT typed again while open: bring the window to the front on the next frame.
|
||||
pub raise: bool,
|
||||
/// Import… was clicked over aliases of the user's own: the dialog asks before a file replaces them.
|
||||
pub confirm_import: bool,
|
||||
}
|
||||
|
||||
/// Whether Import… asks first: the file would replace the user's aliases or the edits not applied yet.
|
||||
pub fn import_replaces_something(st: &AliasDlgState) -> bool {
|
||||
!st.user.is_empty() || !st.applied.is_empty()
|
||||
}
|
||||
|
||||
/// The user aliases and alias file ALIASEDIT holds now.
|
||||
@@ -143,6 +153,82 @@ pub fn apply(app: &mut CadApp) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names a `.pgp` file, whatever its case.
|
||||
fn is_pgp(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("pgp"))
|
||||
}
|
||||
|
||||
/// Report a failed import in the dialog (when it is open) and on the command line; returns it so
|
||||
/// the caller can too.
|
||||
fn refused(app: &mut CadApp, msg: String) -> String {
|
||||
app.session.echo(msg.clone());
|
||||
if let Some(st) = app.alias_dlg.as_mut() {
|
||||
st.error = Some(msg.clone());
|
||||
st.warnings.clear();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Load the `.pgp` alias file at `path` through ALIASEDIT, replacing the user aliases as typing
|
||||
/// `ALIASEDIT {file}` does. The dialog refreshes to what ALIASEDIT holds now, so OK and Apply see
|
||||
/// no pending difference; the file's warnings are kept for display. Non-`.pgp` and unreadable
|
||||
/// files are ordinary errors, never a panic.
|
||||
pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
|
||||
if !is_pgp(path) {
|
||||
return Err(refused(app, format!("Import: {path} is not a .pgp alias file")));
|
||||
}
|
||||
match app.run("aliasedit", json!({ "file": path })) {
|
||||
Ok(v) => {
|
||||
let aliases: BTreeMap<String, String> = v
|
||||
.get("aliases")
|
||||
.and_then(Value::as_object)
|
||||
.map(|m| m.iter().filter_map(|(a, c)| Some((a.clone(), c.as_str()?.to_string()))).collect())
|
||||
.unwrap_or_default();
|
||||
let file = v.get("file").and_then(Value::as_str).unwrap_or(path).to_string();
|
||||
let loaded = v.get("loaded").and_then(Value::as_u64).unwrap_or(0);
|
||||
let warnings: Vec<String> = v
|
||||
.get("warnings")
|
||||
.and_then(Value::as_array)
|
||||
.map(|w| w.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
app.ui.last_pgp_dir = Some(dir.to_string_lossy().to_string());
|
||||
}
|
||||
app.session.echo(format!("{loaded} command alias(es) imported from {path}."));
|
||||
for w in &warnings {
|
||||
app.session.echo(format!("Import {path}: {w}"));
|
||||
}
|
||||
if let Some(st) = app.alias_dlg.as_mut() {
|
||||
st.user = aliases.clone();
|
||||
st.applied = aliases;
|
||||
st.file = Some(file);
|
||||
st.form = None;
|
||||
st.selected = None;
|
||||
st.error = None;
|
||||
st.warnings = warnings;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(refused(app, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Import…: ask the host for a `.pgp` file (starting in the folder the last import came from),
|
||||
/// then load it. A cancelled picker is a no-op; without a native picker (the web) the error names
|
||||
/// the ALIASEDIT `text` form, since the web has no file system to read a path from.
|
||||
pub fn pick_and_import(app: &mut CadApp) -> Result<(), String> {
|
||||
let dir = app.ui.last_pgp_dir.clone();
|
||||
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
|
||||
match picked {
|
||||
None => Err(refused(
|
||||
app,
|
||||
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
|
||||
)),
|
||||
Some(None) => Ok(()),
|
||||
Some(Some(path)) => import(app, &path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if app.alias_dlg.is_none() {
|
||||
@@ -150,7 +236,9 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
}
|
||||
let built = built_in();
|
||||
let warn = Tokens::get().warn;
|
||||
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
|
||||
let (mut ok, mut cancel, mut apply_clicked, mut import_clicked) = (false, false, false, false);
|
||||
// Without a native file picker (the web) Import… can't open one; ALIASEDIT's `text` form loads a file there.
|
||||
let can_pick = app.services.pick_pgp.is_some();
|
||||
let Some(st) = app.alias_dlg.as_mut() else { return };
|
||||
let id = egui::Id::unique("aliasedit_dialog");
|
||||
if std::mem::take(&mut st.raise) {
|
||||
@@ -161,6 +249,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
Some(f) => ui.label(crate::tf!("Alias file: {file}", file = f)),
|
||||
None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")),
|
||||
};
|
||||
ui.label(RichText::new(crate::tl!("Aliases are saved in your CADCraft profile and survive restarts.")).small());
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut st.filter).labelled_by(label.id);
|
||||
@@ -221,7 +310,31 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
{
|
||||
st.user.remove(s);
|
||||
}
|
||||
let import = ui
|
||||
.add_enabled(can_pick, egui::Button::new(crate::tl!("Import…")))
|
||||
.on_disabled_hover_text(crate::tl!("Run ALIASEDIT with the file's contents using its text parameter."));
|
||||
if import.clicked() {
|
||||
if import_replaces_something(st) {
|
||||
st.confirm_import = true;
|
||||
} else {
|
||||
import_clicked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if st.confirm_import {
|
||||
ui.group(|ui| {
|
||||
ui.label(crate::tf!("Importing a file replaces your {n} aliases, and any changes not applied yet.", n = st.user.len()));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(crate::tl!("Import and replace")).clicked() {
|
||||
st.confirm_import = false;
|
||||
import_clicked = true;
|
||||
}
|
||||
if ui.button(crate::tl!("Keep my aliases")).clicked() {
|
||||
st.confirm_import = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let mut save = false;
|
||||
let mut discard = false;
|
||||
if let Some(form) = st.form.as_mut() {
|
||||
@@ -261,6 +374,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if let Some(e) = &st.error {
|
||||
ui.colored_label(warn, e.as_str());
|
||||
}
|
||||
if !st.warnings.is_empty() {
|
||||
for w in st.warnings.iter().take(3) {
|
||||
ui.colored_label(warn, w.as_str());
|
||||
}
|
||||
let more = st.warnings.len().saturating_sub(3);
|
||||
if more > 0 {
|
||||
ui.label(crate::tf!("{n} more warnings; see the command line.", n = more));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ok = ui.button(crate::tl!("OK")).clicked();
|
||||
@@ -268,6 +390,9 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
apply_clicked = ui.button(crate::tl!("Apply")).clicked();
|
||||
});
|
||||
});
|
||||
if import_clicked {
|
||||
let _ = pick_and_import(app);
|
||||
}
|
||||
if (ok && apply(app)) || cancel {
|
||||
*open = false;
|
||||
} else if apply_clicked {
|
||||
@@ -277,3 +402,185 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
app.alias_dlg = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{CadApp, Services};
|
||||
|
||||
use super::{open, pick_and_import};
|
||||
|
||||
/// The alias store is process-wide, so the tests that load files take turns and clear it.
|
||||
fn alone() -> MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-alias-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// An app whose Import picker answers `answer` and records the folder it was asked to start in.
|
||||
fn app_with_pick(answer: Option<String>) -> (CadApp, Rc<RefCell<Vec<Option<String>>>>) {
|
||||
let asked = Rc::new(RefCell::new(Vec::new()));
|
||||
let log = Rc::clone(&asked);
|
||||
let answer = Rc::new(RefCell::new(answer));
|
||||
let services = Services {
|
||||
pick_pgp: Some(Box::new(move |dir: Option<&str>| {
|
||||
log.borrow_mut().push(dir.map(str::to_string));
|
||||
answer.borrow().clone()
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
(CadApp::new(Session::new(), services), asked)
|
||||
}
|
||||
|
||||
fn clear(app: &mut CadApp) {
|
||||
let _ = app.run("aliasedit", json!({ "clear": true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_loads_the_aliases_shows_the_path_and_warnings() {
|
||||
let _guard = alone();
|
||||
let dir = temp_dir("good");
|
||||
let path = dir.join("aliases.pgp");
|
||||
std::fs::write(&path, "; our own aliases\nXXL, *LINE\nXXC, *CIRCLE\nNOPE, *NOTACOMMAND\nEXT, notepad.exe\n").unwrap_or_default();
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let (mut app, asked) = app_with_pick(Some(picked.clone()));
|
||||
open(&mut app);
|
||||
|
||||
pick_and_import(&mut app).expect("the file loads");
|
||||
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
|
||||
assert_eq!(st.user.get("xxc").map(String::as_str), Some("circle"));
|
||||
assert_eq!(st.file.as_deref(), Some(picked.as_str()), "the imported path is shown");
|
||||
assert_eq!(st.applied, st.user, "an import leaves nothing for Apply to resend");
|
||||
assert!(st.error.is_none());
|
||||
assert!(st.warnings.iter().any(|w| w.contains("unknown command")), "unknown commands are reported: {:?}", st.warnings);
|
||||
assert!(st.warnings.iter().any(|w| w.contains("external command")), "external commands are reported: {:?}", st.warnings);
|
||||
assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "the command line shows the full warnings");
|
||||
assert_eq!(app.ui.last_pgp_dir.as_deref(), Some(dir.to_string_lossy().as_ref()), "the import folder is remembered");
|
||||
assert_eq!(asked.borrow().as_slice(), [None], "the first import has no folder yet");
|
||||
|
||||
// The next import starts where the last file was.
|
||||
open(&mut app);
|
||||
let _ = pick_and_import(&mut app);
|
||||
assert_eq!(asked.borrow().as_slice(), [None, Some(dir.to_string_lossy().to_string())]);
|
||||
|
||||
clear(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Import… asks first when a file would replace aliases of the user's own or unapplied edits;
|
||||
/// with neither it opens the picker at once.
|
||||
#[test]
|
||||
fn import_asks_before_replacing_the_users_aliases() {
|
||||
let mut st = super::AliasDlgState::default();
|
||||
assert!(!super::import_replaces_something(&st));
|
||||
st.user.insert("xxl".into(), "line".into());
|
||||
assert!(super::import_replaces_something(&st), "an edit not applied yet");
|
||||
st.applied = st.user.clone();
|
||||
assert!(super::import_replaces_something(&st), "an applied user alias");
|
||||
st.user.clear();
|
||||
assert!(super::import_replaces_something(&st), "a removal not applied yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_picker_changes_nothing() {
|
||||
let _guard = alone();
|
||||
let (mut app, asked) = app_with_pick(None);
|
||||
open(&mut app);
|
||||
let before = app.alias_dlg.clone();
|
||||
|
||||
pick_and_import(&mut app).expect("cancelling is not an error");
|
||||
|
||||
assert_eq!(asked.borrow().len(), 1, "the picker was offered");
|
||||
assert_eq!(app.alias_dlg, before, "a cancelled picker leaves the aliases, file and errors alone");
|
||||
clear(&mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pgp_and_malformed_files_are_ordinary_errors() {
|
||||
let _guard = alone();
|
||||
let dir = temp_dir("bad");
|
||||
let text = dir.join("aliases.txt");
|
||||
std::fs::write(&text, "XXL, *LINE\n").unwrap_or_default();
|
||||
let (mut app, _) = app_with_pick(Some(text.to_string_lossy().to_string()));
|
||||
open(&mut app);
|
||||
let before = app.alias_dlg.clone().unwrap_or_default();
|
||||
|
||||
let e = pick_and_import(&mut app).expect_err("a .txt file is not an alias file");
|
||||
|
||||
assert!(e.contains(".pgp"), "{e}");
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user, before.user, "the refused file changes no alias");
|
||||
assert_eq!(st.file, before.file);
|
||||
assert!(st.error.as_deref().is_some_and(|e| e.contains(".pgp")), "{:?}", st.error);
|
||||
|
||||
// Malformed lines are warnings with the aliases that did parse, never a panic.
|
||||
let broken = dir.join("broken.pgp");
|
||||
std::fs::write(&broken, "not an alias line\nNOSUCH, *NOTACOMMAND\n; comment\nXXL, *LINE\n").unwrap_or_default();
|
||||
let (mut app, _) = app_with_pick(Some(broken.to_string_lossy().to_string()));
|
||||
open(&mut app);
|
||||
pick_and_import(&mut app).expect("malformed lines are warnings");
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
|
||||
assert!(st.warnings.len() >= 2, "{:?}", st.warnings);
|
||||
assert!(st.error.is_none(), "{:?}", st.error);
|
||||
|
||||
// A file that cannot be read is an error, reported in the dialog.
|
||||
let missing = dir.join("missing.pgp");
|
||||
let (mut app, _) = app_with_pick(Some(missing.to_string_lossy().to_string()));
|
||||
open(&mut app);
|
||||
let e = pick_and_import(&mut app).expect_err("a missing file is an ordinary error");
|
||||
assert!(e.contains("missing.pgp"), "{e}");
|
||||
assert!(app.alias_dlg.as_ref().is_some_and(|st| st.error.is_some()));
|
||||
|
||||
clear(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_import_folder_round_trips_through_prefs() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.last_pgp_dir = Some("/tmp/our-aliases".into());
|
||||
let saved = app.prefs_json();
|
||||
assert!(saved.contains("lastPgpDir"), "{saved}");
|
||||
|
||||
let mut restored = CadApp::new(Session::new(), Services::default());
|
||||
restored.load_prefs(&saved);
|
||||
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"));
|
||||
|
||||
restored.load_prefs(r#"{"lastPgpDir": 3}"#);
|
||||
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "malformed values are ignored");
|
||||
restored.load_prefs(r#"{"lastPgpDir": ""}"#);
|
||||
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "an empty value is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_picker_points_at_aliasedit_text_without_changing_aliases() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
open(&mut app);
|
||||
let before = app.alias_dlg.clone();
|
||||
let e = pick_and_import(&mut app).expect_err("the web has no native picker");
|
||||
assert!(e.contains("ALIASEDIT"), "{e}");
|
||||
assert!(e.contains("contents"), "the web is told to give the file's contents: {e}");
|
||||
assert!(e.contains("text"), "the web is told which parameter carries the contents: {e}");
|
||||
assert!(!e.contains("path"), "the web has no file system, so a path is not the supported form: {e}");
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user, before.as_ref().map(|b| b.user.clone()).unwrap_or_default());
|
||||
assert!(st.error.as_deref().is_some_and(|e| e.contains("ALIASEDIT")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,16 @@ pub(super) fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOption
|
||||
}
|
||||
|
||||
/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`.
|
||||
/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu
|
||||
/// offers Edit Text for); false when there is none.
|
||||
/// Start TEXTEDIT on the text, mtext, attribute definition or multileader text at `p` (the objects
|
||||
/// its shortcut menu offers Edit Text for, and multileaders, #1081); false when there is none.
|
||||
pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| {
|
||||
app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_)))
|
||||
app.session.doc().ok()?.entity(h).map(|e| match &e.kind {
|
||||
EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => true,
|
||||
EntityKind::MLeader(m) => m.text.is_some(),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if is_text != Some(true) {
|
||||
return false;
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
//! Pan/Zoom, Quick Select, Find, Properties and Options.
|
||||
//! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the
|
||||
//! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap
|
||||
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off
|
||||
//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu.
|
||||
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click; Options ▸ User
|
||||
//! Preferences ▸ Right-Click Customization sets how long, `rightClickDuration`). With that turned
|
||||
//! off (`timeSensitiveRightClick`), Command mode says what a right-click does
|
||||
//! (`rightClickCommand`: the menu, Enter, or the menu only when the prompt has options).
|
||||
//! `rightClickDefault` and `rightClickEdit` can make a right-click with no command running
|
||||
//! repeat the last command instead of opening the menu.
|
||||
//! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point
|
||||
//! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt.
|
||||
//!
|
||||
@@ -41,7 +45,8 @@ const SETTINGS: &[&str] = &["options"];
|
||||
/// How many recent commands and inputs the menus list.
|
||||
pub const RECENT: usize = 8;
|
||||
/// How long the right button must be held while a command runs to open the command menu instead
|
||||
/// of acting as Enter (AutoCAD's time-sensitive right-click; 250 ms there by default).
|
||||
/// of acting as Enter, by default (AutoCAD's time-sensitive right-click; 250 ms there by
|
||||
/// default). OPTIONS `rightClickDuration` changes it.
|
||||
pub const HOLD_SECS: f64 = 0.25;
|
||||
|
||||
/// Item ids that are not commands: keys, typed text and snap menu items.
|
||||
@@ -313,6 +318,21 @@ pub fn groups_ui(ui: &mut egui::Ui, groups: &[Vec<Entry>], clicked: &mut Option<
|
||||
pub fn show(app: &mut CadApp, resp: &egui::Response) {
|
||||
let mut clicked = None;
|
||||
let snap = app.canvas.snap_menu;
|
||||
// Right-Click Customization: with nothing selected (Default mode) or a selection (Edit mode),
|
||||
// a right-click may repeat the last command instead of opening the menu. With no last command
|
||||
// yet, it opens the menu.
|
||||
let o = &app.options;
|
||||
let mode =
|
||||
if o.right_click_default == o.right_click_edit || app.session.selection().is_empty() { o.right_click_default } else { o.right_click_edit };
|
||||
if !snap
|
||||
&& mode == crate::options::IdleClick::Repeat
|
||||
&& let Some(last) = app.session.last_command.clone()
|
||||
{
|
||||
if resp.secondary_clicked() {
|
||||
app.start(&last);
|
||||
}
|
||||
return;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let groups = if snap { snap_entries(app) } else { entries(app) };
|
||||
groups_ui(ui, &groups, &mut clicked);
|
||||
@@ -330,8 +350,9 @@ struct Press(f64, egui::Pos2);
|
||||
|
||||
/// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a
|
||||
/// quick click is Enter — returns `true` when the caller should send it — and a click held for
|
||||
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with
|
||||
/// the time-sensitive right-click turned off). Call it every frame
|
||||
/// OPTIONS `rightClickDuration` opens the command menu ([`command_entries`]) at the pointer. With
|
||||
/// the time-sensitive right-click turned off, `rightClickCommand` says whether a click opens the
|
||||
/// menu or is Enter. Call it every frame
|
||||
/// with whether the pointer is on the canvas and whether a right-click ended there; it also shows
|
||||
/// the menu while it is open.
|
||||
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
|
||||
@@ -348,14 +369,23 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked:
|
||||
ctx.data_mut(|d| d.insert_temp(press_id, Press(now, p)));
|
||||
}
|
||||
let running = app.session.prompting() && !app.canvas.snap_menu;
|
||||
let o = &app.options;
|
||||
let timed = o.time_sensitive_right_click;
|
||||
// Without the time-sensitive right-click, Command mode says whether a click opens the menu.
|
||||
let menu = match o.right_click_command {
|
||||
crate::options::CommandClick::Menu => true,
|
||||
crate::options::CommandClick::Enter => false,
|
||||
crate::options::CommandClick::MenuWithOptions => app.session.current_prompt().is_some_and(|p| !p.keywords.is_empty()),
|
||||
};
|
||||
let hold = f64::from(o.right_click_hold_ms) / 1000.0;
|
||||
let mut open = false;
|
||||
if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::<Press>(press_id)) {
|
||||
// A drag isn't a click; a click held long enough asks for the menu.
|
||||
let still = pos.is_some_and(|p| p.distance(p0) <= 6.0);
|
||||
let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click;
|
||||
let held = if timed { now - t0 >= hold } else { menu };
|
||||
open = running && still && held;
|
||||
}
|
||||
let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click;
|
||||
let enter = running && inside && right_clicked && !open && (timed || !menu);
|
||||
if !running {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_engine::Input;
|
||||
@@ -87,6 +87,25 @@ impl PendingReply {
|
||||
// Started: a reply that arrived just now still counts.
|
||||
self.rx.try_recv().unwrap_or_else(|_| json!({"ok": false, "error": "timeout", "state": "may-have-run"}))
|
||||
}
|
||||
|
||||
/// The reply if the app has answered, without waiting: a transport that cannot block (the web
|
||||
/// shell) polls this once a frame. A request the app dropped without answering reports that.
|
||||
pub fn try_reply(&self) -> Option<ControlResponse> {
|
||||
match self.rx.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app dropped the request"})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingReply {
|
||||
/// A reply nobody waits for any more cancels a request the app has not started, as a timed-out
|
||||
/// [`PendingReply::wait`] does: the client was told it failed, so the app must not run it late.
|
||||
/// A request the app already started, or answered, is unaffected.
|
||||
fn drop(&mut self) {
|
||||
let _ = self.state.compare_exchange(QUEUED, CANCELLED, Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
@@ -456,7 +475,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(u) => {
|
||||
Ok(mut u) => {
|
||||
// Shortcuts are checked as the preferences check them.
|
||||
u.shortcuts = crate::keys::from_prefs(&json!(u.shortcuts));
|
||||
if let Err(error) = crate::docking::validate(&u.docking) {
|
||||
return err(error);
|
||||
}
|
||||
@@ -726,6 +747,10 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else {
|
||||
return json!({"ok": false, "error": "bad image", "request": token});
|
||||
};
|
||||
// No file system to write to (the web build): the PNG comes back inline, as `ui.render`'s does.
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return inline_screenshot(&img, token);
|
||||
}
|
||||
let saved = match path {
|
||||
Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other),
|
||||
None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()),
|
||||
@@ -736,10 +761,60 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// A screenshot as an inline PNG reply (`pngBase64`, `width`, `height`, `bytes`, like `ui.render`), for a
|
||||
/// build that has no file system to write it to: the web build, where `std::env::temp_dir` panics.
|
||||
fn inline_screenshot(img: &image::RgbaImage, token: u64) -> Value {
|
||||
let (w, h) = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) {
|
||||
return json!({"ok": false, "error": e.to_string(), "request": token});
|
||||
}
|
||||
json!({"ok": true, "request": token, "result": {"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "bytes": png.len()}})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_inline_screenshot_carries_the_png_and_its_size() {
|
||||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255]));
|
||||
let reply = inline_screenshot(&img, 7);
|
||||
assert_eq!(reply["ok"], true);
|
||||
assert_eq!(reply["request"], 7);
|
||||
assert_eq!((reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()), (Some(3), Some(2)));
|
||||
let b64 = reply["result"]["pngBase64"].as_str().unwrap_or_default();
|
||||
assert!(b64.starts_with("iVBORw0KGgo"), "a PNG signature in base64: {b64:.20}");
|
||||
assert!(reply["result"]["bytes"].as_u64().is_some_and(|n| n > 30), "the PNG byte count is reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_pending_reply_cancels_a_request_the_app_has_not_started() {
|
||||
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
|
||||
drop(pending);
|
||||
assert!(!request.begin(), "the app must skip a request nobody waits for");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_pending_reply_leaves_a_started_request_alone() {
|
||||
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
|
||||
assert!(request.begin());
|
||||
drop(pending);
|
||||
assert_eq!(request.state.load(Ordering::Acquire), STARTED, "a started request is not cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_reply_polls_without_blocking() {
|
||||
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
|
||||
assert_eq!(pending.try_reply(), None, "no answer yet");
|
||||
assert!(request.begin());
|
||||
request.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(pending.try_reply(), Some(json!({"ok": true, "result": 1})));
|
||||
let (request, pending) = ControlRequest::new("ui.inspect", json!({}));
|
||||
drop(request);
|
||||
assert_eq!(pending.try_reply().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_view_fit_finds_offscreen_drawing() {
|
||||
let far = cadcraft_geom::Bounds2::new(Vec2::new(9000.0, 9000.0), Vec2::new(9100.0, 9050.0));
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
"hatch" => crate::hatchdlg::dialog(app, ctx, &mut open),
|
||||
"aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),
|
||||
"find" => crate::finddlg::dialog(app, ctx, &mut open),
|
||||
crate::keys::DIALOG => crate::keys::dialog(app, ctx, &mut open),
|
||||
"about" => about(ctx, &mut open),
|
||||
"preview" => crate::exchange::dialog(app, ctx, &mut open),
|
||||
"commands" => commands(app, ctx, &mut open),
|
||||
|
||||
@@ -1029,3 +1029,10 @@
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Le dessin a changé. Fermez la fenêtre et rouvrez-la.
|
||||
Keyboard Shortcuts... Raccourcis clavier...
|
||||
Keyboard Shortcuts Raccourcis clavier
|
||||
Replaces the shortcut of {commands} Remplace le raccourci de : {commands}
|
||||
Needs Ctrl, Alt or a function key Il faut Ctrl, Alt ou une touche de fonction
|
||||
Press the keys Appuyez sur les touches
|
||||
Back to CADCraft's shortcut Revenir au raccourci de CADCraft
|
||||
A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Un raccourci que vous définissez l'emporte sur celui de CADCraft. Les modifications s'appliquent tout de suite et sont conservées.
|
||||
|
||||
|
@@ -1381,3 +1381,20 @@ menu Modify Edita
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Il disegno è cambiato. Chiudere la finestra e riaprirla.
|
||||
Right-Click Customization... Personalizzazione pulsante destro...
|
||||
Right-Click Customization Personalizzazione pulsante destro
|
||||
Turn on time-sensitive right-click Attiva clic destro sensibile al tempo
|
||||
Longer click duration: Durata clic lungo:
|
||||
milliseconds millisecondi
|
||||
While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Durante un comando un clic rapido equivale a Invio, un clic più lungo apre il menu di scelta rapida.
|
||||
Default Mode Modalità predefinita
|
||||
No objects selected and no command running: Nessun oggetto selezionato e nessun comando in corso:
|
||||
Edit Mode Modalità modifica
|
||||
Objects selected and no command running: Oggetti selezionati e nessun comando in corso:
|
||||
Repeat last command Ripeti ultimo comando
|
||||
Shortcut Menu Menu di scelta rapida
|
||||
Command Mode Modalità comando
|
||||
While a command runs: Durante un comando:
|
||||
ENTER INVIO
|
||||
Shortcut Menu: always enabled Menu di scelta rapida: sempre attivo
|
||||
Shortcut Menu: enabled when command options are present Menu di scelta rapida: attivo se il comando ha opzioni
|
||||
|
||||
|
@@ -105,6 +105,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
"dialogs",
|
||||
"hatchdlg",
|
||||
"aliasdlg",
|
||||
"keys",
|
||||
"mtext_editor",
|
||||
"layers",
|
||||
"bedit",
|
||||
@@ -160,18 +161,13 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
|
||||
/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned.
|
||||
fn module_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
||||
if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
// `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped,
|
||||
// not walked again as a new subtree.
|
||||
let mut paths: Vec<_> = entries.flatten().filter_map(|e| Some((e.path(), e.file_type().ok()?))).collect();
|
||||
paths.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (path, kind) in paths {
|
||||
if kind.is_dir() {
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
if path.is_dir() {
|
||||
module_files(&path, files);
|
||||
} else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") {
|
||||
} else if path.extension().is_some_and(|x| x == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,12 +1364,17 @@
|
||||
More than {n} aliases. Понад {n} псевдонімів.
|
||||
Alias file: {file} Файл псевдонімів: {file}
|
||||
No alias file loaded. REINIT replaces these aliases with a file's. Файл псевдонімів не завантажено. REINIT замінює ці псевдоніми псевдонімами з файлу.
|
||||
Aliases are saved in your CADCraft profile and survive restarts. Псевдоніми зберігаються в профілі CADCraft і не зникають після перезапуску.
|
||||
Alias Псевдонім
|
||||
Command Команда
|
||||
overrides built-in замінює вбудований
|
||||
built-in вбудований
|
||||
Add… Додати…
|
||||
Edit… Змінити…
|
||||
Import… Імпортувати…
|
||||
Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter. Імпорт: тут немає діалогу вибору файлів; запустіть ALIASEDIT із вмістом файлу в параметрі text.
|
||||
{n} more warnings; see the command line. Ще {n} попереджень; див. командний рядок.
|
||||
Alias files (PGP) Файли псевдонімів (PGP)
|
||||
Save alias Зберегти псевдонім
|
||||
Discard Скасувати зміни
|
||||
Customize Налаштування інтерфейсу
|
||||
@@ -1393,3 +1398,27 @@
|
||||
Ctrl+Enter to apply Ctrl+Enter to apply
|
||||
Edit Text Edit Text
|
||||
The drawing changed. Close the window and open it again. Креслення змінилося. Закрийте вікно та відкрийте його знову.
|
||||
Keyboard Shortcuts... Комбінації клавіш...
|
||||
Keyboard Shortcuts Комбінації клавіш
|
||||
Replaces the shortcut of {commands} Замінює комбінацію клавіш команди: {commands}
|
||||
Needs Ctrl, Alt or a function key Потрібна клавіша Ctrl, Alt або функціональна клавіша
|
||||
Press the keys Натисніть клавіші
|
||||
Back to CADCraft's shortcut Повернути комбінацію CADCraft
|
||||
A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Ваша комбінація клавіш має перевагу над комбінацією CADCraft. Зміни застосовуються одразу й зберігаються.
|
||||
Right-Click Customization... Налаштування правої кнопки...
|
||||
Right-Click Customization Налаштування правої кнопки
|
||||
Turn on time-sensitive right-click Увімкнути залежне від часу клацання правою кнопкою
|
||||
Longer click duration: Тривалість довгого клацання:
|
||||
milliseconds мілісекунд
|
||||
While a command runs, a quick click is Enter and a longer click opens the shortcut menu. Під час команди коротке клацання — Enter, довше відкриває контекстне меню.
|
||||
Default Mode Режим за замовчуванням
|
||||
No objects selected and no command running: Нічого не вибрано й команда не виконується:
|
||||
Edit Mode Режим редагування
|
||||
Objects selected and no command running: Об'єкти вибрано, команда не виконується:
|
||||
Repeat last command Повторити останню команду
|
||||
Shortcut Menu Контекстне меню
|
||||
Command Mode Режим команди
|
||||
While a command runs: Під час виконання команди:
|
||||
ENTER Клавіша Enter
|
||||
Shortcut Menu: always enabled Контекстне меню: завжди
|
||||
Shortcut Menu: enabled when command options are present Контекстне меню: коли команда має параметри
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1385 and column 24.
|
@@ -362,6 +362,7 @@
|
||||
Join 合并
|
||||
Join the ArtCraft Discord… 加入 ArtCraft Discord…
|
||||
Join us on Discord 在 Discord 上加入我们
|
||||
Keyboard Shortcuts... 键盘快捷键...
|
||||
Language 语言
|
||||
Last 最后
|
||||
Last commit 最后提交
|
||||
|
||||
|
@@ -0,0 +1,697 @@
|
||||
//! The user's keyboard shortcuts (Tools ▸ Customize ▸ Keyboard Shortcuts...): any command can get
|
||||
//! one, and a shortcut the user gives wins over CADCraft's own; the command that had it loses it,
|
||||
//! and the dialog says so. They are kept with the other preferences (`shortcuts`).
|
||||
//!
|
||||
//! Recording a shortcut (click, press the keys, Esc to cancel) and the conflict rules come from
|
||||
//! the storey shortcuts of #1703 (@Alex-BB44).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{Key, KeyboardShortcut, Modifiers, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The user's shortcuts: command id → registry shortcut ("Cmd+Shift+K"). Empty: the command has
|
||||
/// none (its built-in one is removed).
|
||||
pub type UserKeys = BTreeMap<String, String>;
|
||||
|
||||
/// At most this many user shortcuts are kept; longer shortcut strings are refused (preferences
|
||||
/// and JSON calls are hostile input).
|
||||
pub const MAX_KEYS: usize = 1000;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Commands whose built-in key is handled outside the shortcut table and stays as it is: the
|
||||
/// clipboard keys, Shift ones included, arrive as clipboard events, Delete belongs to the command
|
||||
/// line, Cmd+Q to the macOS app menu.
|
||||
const FIXED: &[&str] = &["copyclip", "cutclip", "pasteclip", "copybase", "pasteblock", "erase.selection", "ui.quit"];
|
||||
|
||||
/// The dialog name in `UiState::dialog`.
|
||||
pub const DIALOG: &str = "shortcuts";
|
||||
|
||||
/// Whether two shortcuts are one key press. Outside macOS Ctrl is the command key, and one Ctrl
|
||||
/// press runs a Ctrl and a Cmd shortcut alike, so they are compared as Cmd.
|
||||
fn same(a: &KeyboardShortcut, b: &KeyboardShortcut) -> bool {
|
||||
let pressed = |m: Modifiers| {
|
||||
if !cfg!(target_os = "macos") && (m.ctrl || m.command || m.mac_cmd) {
|
||||
Modifiers { ctrl: false, command: true, mac_cmd: false, ..m }
|
||||
} else {
|
||||
m
|
||||
}
|
||||
};
|
||||
a.logical_key == b.logical_key && pressed(a.modifiers) == pressed(b.modifiers)
|
||||
}
|
||||
|
||||
/// A registry shortcut string ("Cmd+Shift+G", "Alt+4", "F3") as a shortcut; `None` when empty or
|
||||
/// not understood. Cmd is the command key (Ctrl on Windows and Linux); Ctrl alone is the Control
|
||||
/// key of macOS, and Cmd+Ctrl both macOS keys.
|
||||
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||||
let mut m = Modifiers::NONE;
|
||||
let mut key = None;
|
||||
for part in s.split('+').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.to_ascii_lowercase().as_str() {
|
||||
"cmd" | "command" => m |= Modifiers::COMMAND,
|
||||
// Control on macOS (on Windows and Linux, Ctrl is written Cmd: it is the command key).
|
||||
"ctrl" => m |= Modifiers::CTRL,
|
||||
"shift" => m |= Modifiers::SHIFT,
|
||||
"alt" | "option" => m |= Modifiers::ALT,
|
||||
_ if key.is_none() => key = Some(Key::from_name(part)?),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if m.command && m.ctrl {
|
||||
m.mac_cmd = true;
|
||||
}
|
||||
key.map(|k| KeyboardShortcut::new(m, k))
|
||||
}
|
||||
|
||||
/// A shortcut as a registry string ("Cmd+Shift+G"), the same on every platform: Cmd for the
|
||||
/// command key, Ctrl for a Control that isn't it (macOS), so [`parse`] gives it back.
|
||||
pub fn format(m: Modifiers, k: Key) -> String {
|
||||
let mut s = String::new();
|
||||
if m.command || m.mac_cmd {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
if m.ctrl && (m.mac_cmd || !m.command) {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
// "+" and "-" would read as separators.
|
||||
s.push_str(match k {
|
||||
Key::Plus => "Plus",
|
||||
Key::Minus => "Minus",
|
||||
k => k.name(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether `s` can be a user shortcut: with Cmd, Ctrl or Alt, or a function or paging key alone.
|
||||
/// The keys the drawing area and the command line live on (letters as typing, Space, Enter, Tab,
|
||||
/// Esc, Delete, Backspace, the arrows), the clipboard keys and Cmd+Q never are.
|
||||
pub fn allowed(s: &KeyboardShortcut) -> bool {
|
||||
let k = s.logical_key;
|
||||
let never =
|
||||
[Key::Space, Key::Enter, Key::Tab, Key::Escape, Key::Delete, Key::Backspace, Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight];
|
||||
if never.contains(&k) {
|
||||
return false;
|
||||
}
|
||||
let m = s.modifiers;
|
||||
let command = m.command || m.ctrl || m.mac_cmd;
|
||||
// Cmd+C, X and V arrive as clipboard events whatever else is held (Shift+Cmd+C is Copy with
|
||||
// Base Point, Shift+Cmd+V Paste as Block).
|
||||
let reserved = command && ([Key::C, Key::X, Key::V].contains(&k) || k == Key::Q && !m.shift && !m.alt);
|
||||
let alone_ok = matches!(k, Key::PageUp | Key::PageDown | Key::Home | Key::End) || k.name().starts_with('F') && k.name().len() > 1;
|
||||
!reserved && (command || m.alt || alone_ok)
|
||||
}
|
||||
|
||||
/// Shortcuts are tried most specific first (egui lets a shortcut match with Shift or Alt held on
|
||||
/// top), so Cmd+Shift+G reaches its own command before Cmd+G does.
|
||||
pub fn by_specificity<T>(pairs: &mut [(KeyboardShortcut, T)]) {
|
||||
let weight = |s: &KeyboardShortcut| {
|
||||
let m = s.modifiers;
|
||||
u8::from(m.shift) + u8::from(m.alt) + u8::from(m.ctrl && (m.mac_cmd || !m.command)) + u8::from(m.command || m.mac_cmd)
|
||||
};
|
||||
pairs.sort_by_key(|(s, _)| std::cmp::Reverse(weight(s)));
|
||||
}
|
||||
|
||||
/// CADCraft's own shortcuts: the shortcut table, plus Cmd+8 (QuickCalc), which the shortcut
|
||||
/// handler reads before a focused text field can take it.
|
||||
fn defaults() -> Vec<(KeyboardShortcut, &'static str)> {
|
||||
let mut v = crate::menus::builtin_shortcuts();
|
||||
v.push((KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8), "ui.toggle.quickcalc"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The command a built-in shortcut belongs to in the editor: the table runs some commands' UI
|
||||
/// form (`ui.open` for OPEN), which is the same command for the user.
|
||||
fn owner(id: &str) -> &str {
|
||||
id.strip_prefix("ui.").filter(|c| cadcraft_engine::find_command(c).is_some()).unwrap_or(id)
|
||||
}
|
||||
|
||||
fn known(id: &str) -> bool {
|
||||
cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)
|
||||
}
|
||||
|
||||
/// The shortcuts in force: the user's first, then CADCraft's own unless the user gave that
|
||||
/// command another shortcut or gave the key to another command. Most specific first.
|
||||
pub fn effective(user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {
|
||||
let mut out: Vec<(KeyboardShortcut, String)> = Vec::new();
|
||||
for (id, key) in user {
|
||||
if let Some(s) = parse(key).filter(allowed)
|
||||
&& !out.iter().any(|(o, _)| same(o, &s))
|
||||
{
|
||||
out.push((s, id.clone()));
|
||||
}
|
||||
}
|
||||
for (s, id) in defaults() {
|
||||
if !user.contains_key(owner(id)) && !out.iter().any(|(o, _)| same(o, &s)) {
|
||||
out.push((s, id.to_string()));
|
||||
}
|
||||
}
|
||||
by_specificity(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`effective`], worked out again only when the user's shortcuts change (the shortcut handler
|
||||
/// asks every frame).
|
||||
pub fn effective_cached(ctx: &egui::Context, user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {
|
||||
let id = egui::Id::unique("user_keys_effective");
|
||||
if let Some((k, v)) = ctx.data(|m| m.get_temp::<(UserKeys, Vec<(KeyboardShortcut, String)>)>(id))
|
||||
&& k == *user
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let v = effective(user);
|
||||
ctx.data_mut(|m| m.insert_temp(id, (user.clone(), v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// The shortcut a menu or the editor shows for `id`, whose built-in one is `default`: the user's,
|
||||
/// or the built-in one unless the user gave that key to another command.
|
||||
pub fn shown(user: &UserKeys, id: &str, default: Option<&str>) -> Option<String> {
|
||||
if let Some(k) = user.get(id) {
|
||||
return (!k.is_empty()).then(|| k.clone());
|
||||
}
|
||||
let d = default?;
|
||||
let s = parse(d);
|
||||
let taken = s.is_some_and(|s| user.iter().any(|(o, k)| o != id && parse(k).is_some_and(|u| same(&u, &s))));
|
||||
(!taken).then(|| d.to_string())
|
||||
}
|
||||
|
||||
/// The built-in shortcut a command shows (its registry `key`, or a UI command's).
|
||||
pub fn default_key(id: &str) -> Option<&'static str> {
|
||||
cadcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3))
|
||||
}
|
||||
|
||||
/// Give `id` the shortcut `key` ("" removes its shortcut). The commands that had the key, built
|
||||
/// in or the user's, lose it; they are returned so the caller can say so.
|
||||
pub fn assign(user: &mut UserKeys, id: &str, key: &str) -> Result<Vec<String>, String> {
|
||||
if !known(id) {
|
||||
return Err(format!("{id}: unknown command"));
|
||||
}
|
||||
if FIXED.contains(&id) {
|
||||
return Err(format!("{id}: this shortcut can't be changed"));
|
||||
}
|
||||
let key = key.trim();
|
||||
if key.len() > MAX_LEN {
|
||||
return Err("shortcut too long".into());
|
||||
}
|
||||
if user.len() >= MAX_KEYS && !user.contains_key(id) {
|
||||
return Err(format!("more than {MAX_KEYS} shortcuts"));
|
||||
}
|
||||
if key.is_empty() {
|
||||
user.insert(id.to_string(), String::new());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let s = parse(key).ok_or_else(|| format!("{key}: not a shortcut"))?;
|
||||
if !allowed(&s) {
|
||||
return Err(format!("{key}: needs Ctrl, Alt or a function key"));
|
||||
}
|
||||
let mut replaced = Vec::new();
|
||||
for (other, k) in user.iter_mut() {
|
||||
if other != id && parse(k).is_some_and(|o| same(&o, &s)) {
|
||||
k.clear();
|
||||
replaced.push(other.clone());
|
||||
}
|
||||
}
|
||||
// A built-in owner already lost the key to the user command that had it.
|
||||
let held = !replaced.is_empty();
|
||||
for (o, other) in defaults() {
|
||||
let other = owner(other);
|
||||
if !held && other != id && same(&o, &s) && !user.contains_key(other) && !replaced.iter().any(|r| r == other) {
|
||||
replaced.push(other.to_string());
|
||||
}
|
||||
}
|
||||
user.insert(id.to_string(), format(s.modifiers, s.logical_key));
|
||||
Ok(replaced)
|
||||
}
|
||||
|
||||
/// The user's shortcuts from saved preferences: unknown commands, bad strings, a shortcut an
|
||||
/// earlier command already has (it would show on both, run one) and anything past [`MAX_KEYS`]
|
||||
/// are dropped.
|
||||
pub fn from_prefs(v: &Value) -> UserKeys {
|
||||
let Some(m) = v.as_object() else { return UserKeys::new() };
|
||||
let mut out = UserKeys::new();
|
||||
let mut taken: Vec<KeyboardShortcut> = Vec::new();
|
||||
for (id, k) in m.iter().filter_map(|(id, k)| Some((id, k.as_str()?))) {
|
||||
if out.len() >= MAX_KEYS {
|
||||
break;
|
||||
}
|
||||
if id.len() > MAX_LEN || k.len() > MAX_LEN || !known(id) || FIXED.contains(&id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !k.is_empty() {
|
||||
let Some(s) = parse(k).filter(allowed) else { continue };
|
||||
if taken.iter().any(|o| same(o, &s)) {
|
||||
continue;
|
||||
}
|
||||
taken.push(s);
|
||||
}
|
||||
out.insert(id.clone(), k.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `ui.shortcuts`: from a menu or typed, the Keyboard Shortcuts dialog; with parameters
|
||||
/// `{set?: {command: shortcut or ""}, reset?: bool}` the JSON form, which answers the user's
|
||||
/// shortcuts and the commands that lost theirs.
|
||||
pub fn command(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
if params.is_null() {
|
||||
app.ui.dialog = Some(DIALOG.into());
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let mut keys = app.ui.shortcuts.clone();
|
||||
if params.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
keys.clear();
|
||||
}
|
||||
let mut replaced = Vec::new();
|
||||
if let Some(set) = params.get("set") {
|
||||
let set = set.as_object().ok_or("`set` must be an object of command: shortcut")?;
|
||||
if set.len() > MAX_KEYS {
|
||||
return Err(format!("more than {MAX_KEYS} shortcuts"));
|
||||
}
|
||||
for (id, key) in set {
|
||||
let key = key.as_str().ok_or_else(|| format!("{id}: the shortcut must be a string"))?;
|
||||
replaced.extend(assign(&mut keys, &id.to_ascii_lowercase(), key)?);
|
||||
}
|
||||
}
|
||||
app.ui.shortcuts = keys;
|
||||
Ok(json!({ "shortcuts": app.ui.shortcuts, "replaced": replaced }))
|
||||
}
|
||||
|
||||
const RECORDING: &str = "user_keys_recording";
|
||||
|
||||
/// Whether a shortcut is being recorded (every shortcut stays quiet meanwhile).
|
||||
pub fn recording(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|m| m.get_temp::<String>(egui::Id::unique(RECORDING))).is_some()
|
||||
}
|
||||
|
||||
pub fn stop_recording(ctx: &egui::Context) {
|
||||
ctx.data_mut(|m| m.remove::<String>(egui::Id::unique(RECORDING)));
|
||||
}
|
||||
|
||||
/// The commands the editor lists, sorted by the label shown: (id, label, what tells same labels
|
||||
/// apart: the command name to type, or the menu an interface command sits in).
|
||||
fn rows() -> Vec<(&'static str, String, String)> {
|
||||
let mut v: Vec<(&'static str, String, String)> = Vec::new();
|
||||
for c in cadcraft_engine::command_specs() {
|
||||
if !c.label.is_empty() && !v.iter().any(|(id, ..)| *id == c.id) {
|
||||
v.push((c.id, crate::i18n::t(c.label).to_string(), c.id.to_ascii_uppercase()));
|
||||
}
|
||||
}
|
||||
for (id, label, menu, _) in crate::menus::UI_COMMANDS {
|
||||
if !label.is_empty() && !menu.is_empty() && owner(id) == *id && !v.iter().any(|(i, ..)| i == id) {
|
||||
let path: Vec<&str> = menu.iter().take(menu.len().saturating_sub(1)).map(|m| crate::i18n::menu(m)).collect();
|
||||
v.push((id, crate::i18n::t(label).to_string(), path.join(" ▸ ")));
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(b.0)));
|
||||
v
|
||||
}
|
||||
|
||||
fn label_of(id: &str) -> String {
|
||||
cadcraft_engine::find_command(id)
|
||||
.map(|c| crate::i18n::t(c.label).to_string())
|
||||
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| crate::i18n::t(c.1).to_string()))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts dialog: every command with its shortcut, a filter, a click to record a
|
||||
/// new one. Changes apply at once.
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
let mac = ctx.os().is_mac();
|
||||
let rec_id = egui::Id::unique(RECORDING);
|
||||
let filter_id = egui::Id::unique("user_keys_filter");
|
||||
let note_id = egui::Id::unique("user_keys_note");
|
||||
let mut recording: Option<String> = ctx.data(|m| m.get_temp(rec_id));
|
||||
let mut filter: String = ctx.data(|m| m.get_temp(filter_id)).unwrap_or_default();
|
||||
let mut note: Option<(String, bool)> = ctx.data(|m| m.get_temp(note_id));
|
||||
// A key pressed while recording becomes that command's shortcut; Esc stops recording.
|
||||
if let Some(id) = recording.clone() {
|
||||
let pressed = ctx.input(|inp| {
|
||||
inp.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((k, m)) = pressed {
|
||||
if k != Key::Escape {
|
||||
note = Some(match assign(&mut app.ui.shortcuts, &id, &format(m, k)) {
|
||||
Ok(lost) if lost.is_empty() => (String::new(), false),
|
||||
Ok(lost) => {
|
||||
let names: Vec<String> = lost.iter().map(|l| label_of(l)).collect();
|
||||
(crate::tf!("Replaces the shortcut of {commands}", commands = names.join(", ")), false)
|
||||
}
|
||||
Err(_) => (crate::tl!("Needs Ctrl, Alt or a function key").to_string(), true),
|
||||
});
|
||||
}
|
||||
recording = None;
|
||||
ctx.input_mut(|inp| inp.events.clear());
|
||||
}
|
||||
}
|
||||
let mut reset_all = false;
|
||||
egui::Window::new(crate::tl!("Keyboard Shortcuts"))
|
||||
.id(egui::Id::unique("shortcuts_dialog"))
|
||||
.open(open)
|
||||
.collapsible(false)
|
||||
.default_size(vec2(600.0, 520.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Filter"));
|
||||
ui.text_edit_singleline(&mut filter);
|
||||
});
|
||||
let f = filter.trim().to_lowercase();
|
||||
let rows: Vec<(&'static str, String, String)> = rows()
|
||||
.into_iter()
|
||||
.filter(|(_, label, detail)| f.is_empty() || label.to_lowercase().contains(&f) || detail.to_lowercase().contains(&f))
|
||||
.collect();
|
||||
let row_h = ui.spacing().interact_size.y + 4.0;
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
for (id, label, detail) in rows.get(range).unwrap_or_default() {
|
||||
ui.horizontal(|ui| {
|
||||
let cell = |ui: &mut egui::Ui, w: f32, text: RichText| {
|
||||
ui.allocate_ui_with_layout(vec2(w, row_h - 4.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_width(w);
|
||||
ui.add(egui::Label::new(text).truncate());
|
||||
});
|
||||
};
|
||||
cell(ui, 190.0, RichText::new(label.as_str()));
|
||||
cell(ui, 140.0, RichText::new(detail.as_str()).color(t.text_dim).small());
|
||||
let fixed = FIXED.contains(id);
|
||||
let key = shown(&app.ui.shortcuts, id, default_key(id));
|
||||
let text = if recording.as_deref() == Some(*id) {
|
||||
crate::tl!("Press the keys").to_string()
|
||||
} else {
|
||||
key.as_deref().map_or_else(|| crate::tl!("None").to_string(), |k| crate::menus::shortcut_label(k, mac))
|
||||
};
|
||||
let b = egui::Button::new(text).selected(recording.as_deref() == Some(*id)).min_size(vec2(120.0, 0.0));
|
||||
if ui.add_enabled(!fixed, b).clicked() {
|
||||
recording = Some(id.to_string());
|
||||
note = None;
|
||||
}
|
||||
if !fixed && key.is_some() && ui.small_button(crate::tl!("Clear")).clicked() {
|
||||
let _ = assign(&mut app.ui.shortcuts, id, "");
|
||||
}
|
||||
if app.ui.shortcuts.contains_key(*id)
|
||||
&& ui.small_button(crate::tl!("Default")).on_hover_text(crate::tl!("Back to CADCraft's shortcut")).clicked()
|
||||
{
|
||||
app.ui.shortcuts.remove(*id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
match ¬e {
|
||||
Some((n, true)) => {
|
||||
ui.colored_label(t.warn, n.as_str());
|
||||
}
|
||||
Some((n, false)) if !n.is_empty() => {
|
||||
ui.label(RichText::new(n.as_str()).color(t.text_dim));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
reset_all = ui.button(crate::tl!("Reset to Defaults")).clicked();
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(crate::tl!("A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
if reset_all {
|
||||
app.ui.shortcuts.clear();
|
||||
note = None;
|
||||
}
|
||||
if !*open {
|
||||
recording = None;
|
||||
note = None;
|
||||
}
|
||||
ctx.data_mut(|m| {
|
||||
match &recording {
|
||||
Some(r) => {
|
||||
m.insert_temp(rec_id, r.clone());
|
||||
}
|
||||
None => {
|
||||
m.remove::<String>(rec_id);
|
||||
}
|
||||
}
|
||||
m.insert_temp(filter_id, filter);
|
||||
match note {
|
||||
Some(n) => {
|
||||
m.insert_temp(note_id, n);
|
||||
}
|
||||
None => {
|
||||
m.remove::<(String, bool)>(note_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
fn app() -> CadApp {
|
||||
CadApp::new(Session::new(), crate::Services::default())
|
||||
}
|
||||
|
||||
fn key(s: &str) -> KeyboardShortcut {
|
||||
parse(s).unwrap_or_else(|| panic!("{s}"))
|
||||
}
|
||||
|
||||
fn runs(user: &UserKeys, s: &str) -> Option<String> {
|
||||
effective(user).into_iter().find(|(o, _)| same(o, &key(s))).map(|(_, id)| id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_strings_round_trip() {
|
||||
for s in ["Cmd+Shift+G", "Cmd+4", "F3", "Alt+PageUp", "Cmd+Plus", "Cmd+Minus", "Ctrl+K", "Cmd+Ctrl+K"] {
|
||||
let k = key(s);
|
||||
assert_eq!(format(k.modifiers, k.logical_key), s);
|
||||
}
|
||||
// Keys as egui reports them: Ctrl on Linux and Windows is the command key; macOS Control
|
||||
// and Command+Control keep their Control.
|
||||
let linux_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
let mac_ctrl = Modifiers { ctrl: true, ..Modifiers::NONE };
|
||||
let mac_cmd_ctrl = Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE };
|
||||
assert_eq!(format(linux_ctrl, Key::K), "Cmd+K");
|
||||
assert_eq!(format(mac_ctrl, Key::K), "Ctrl+K");
|
||||
assert_eq!(format(mac_cmd_ctrl, Key::K), "Cmd+Ctrl+K");
|
||||
assert!(parse("").is_none() && parse("Cmd+").is_none() && parse("Cmd+G+H").is_none() && parse("Hyper+G").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keys_clipboard_keys_and_quit_are_never_user_shortcuts() {
|
||||
for s in [
|
||||
"L",
|
||||
"Shift+L",
|
||||
"Space",
|
||||
"Enter",
|
||||
"Escape",
|
||||
"Cmd+C",
|
||||
"Cmd+V",
|
||||
"Cmd+X",
|
||||
"Cmd+Shift+C",
|
||||
"Cmd+Shift+V",
|
||||
"Cmd+Alt+C",
|
||||
"Cmd+Alt+X",
|
||||
"Cmd+Alt+V",
|
||||
"Cmd+Q",
|
||||
"ArrowUp",
|
||||
"Delete",
|
||||
] {
|
||||
assert!(!allowed(&key(s)), "{s}");
|
||||
}
|
||||
for s in ["Cmd+L", "Alt+L", "F4", "PageDown", "Cmd+Shift+Q", "Ctrl+K"] {
|
||||
assert!(allowed(&key(s)), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_command_gets_a_shortcut_and_it_runs_it() {
|
||||
let mut user = UserKeys::new();
|
||||
assert_eq!(assign(&mut user, "line", "Cmd+L"), Ok(vec![]));
|
||||
assert_eq!(runs(&user, "Cmd+L").as_deref(), Some("line"));
|
||||
// The built-in ones are untouched.
|
||||
assert_eq!(runs(&user, "Cmd+S").as_deref(), Some("qsave"));
|
||||
assert_eq!(runs(&UserKeys::new(), "Cmd+L"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_and_command_control_shortcuts_keep_their_control() {
|
||||
let mut user = UserKeys::new();
|
||||
assert_eq!(assign(&mut user, "line", "Ctrl+K"), Ok(vec![]));
|
||||
// Outside macOS both are one Ctrl press, so Circle takes it from Line.
|
||||
let lost = if cfg!(target_os = "macos") { vec![] } else { vec!["line".to_string()] };
|
||||
assert_eq!(assign(&mut user, "circle", "Cmd+Ctrl+K"), Ok(lost));
|
||||
assert_eq!(user.get("circle").map(String::as_str), Some("Cmd+Ctrl+K"));
|
||||
let eff = effective(&user);
|
||||
let of = |id: &str| eff.iter().find(|(_, i)| i == id).map(|(s, _)| s.modifiers);
|
||||
assert_eq!(of("circle"), Some(Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE }));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(user.get("line").map(String::as_str), Some("Ctrl+K"));
|
||||
assert_eq!(of("line"), Some(Modifiers::CTRL));
|
||||
// Command+Control+K is tried before Control+K, which it holds too.
|
||||
let at = |id: &str| eff.iter().position(|(_, i)| i == id);
|
||||
assert!(at("circle") < at("line"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_shortcut_wins_over_a_built_in_one_and_says_which() {
|
||||
let mut user = UserKeys::new();
|
||||
assert_eq!(assign(&mut user, "line", "Cmd+P"), Ok(vec!["plot".to_string()]));
|
||||
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("line"));
|
||||
assert_eq!(shown(&user, "plot", default_key("plot")), None, "Plot no longer shows Cmd+P");
|
||||
assert_eq!(shown(&user, "line", default_key("line")).as_deref(), Some("Cmd+P"));
|
||||
// Given to a third command, the first user one loses it too.
|
||||
assert_eq!(assign(&mut user, "circle", "Cmd+P"), Ok(vec!["line".to_string()]));
|
||||
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("circle"));
|
||||
assert_eq!(shown(&user, "line", default_key("line")), None);
|
||||
// A table entry that runs a command's UI form belongs to that command (Cmd+O, OPEN).
|
||||
assert_eq!(assign(&mut user, "line", "Cmd+O"), Ok(vec!["open".to_string()]));
|
||||
assert_eq!(assign(&mut user, "line", "Cmd+8"), Ok(vec!["ui.toggle.quickcalc".to_string()]));
|
||||
}
|
||||
|
||||
/// On Windows and Linux one Ctrl press runs a Ctrl and a Cmd shortcut alike, so they are one
|
||||
/// key: giving it takes it from the command that had it, and preferences keep it once.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn ctrl_and_cmd_are_one_key_outside_macos() {
|
||||
let mut user = UserKeys::new();
|
||||
assert_eq!(assign(&mut user, "line", "Ctrl+P"), Ok(vec!["plot".to_string()]));
|
||||
assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("line"));
|
||||
assert_eq!(assign(&mut user, "circle", "Cmd+P"), Ok(vec!["line".to_string()]));
|
||||
let user = from_prefs(&json!({"circle": "Cmd+K", "line": "Ctrl+K"}));
|
||||
assert_eq!(user.values().filter(|k| !k.is_empty()).count(), 1, "{user:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_shortcut_replaces_the_commands_own_and_clear_removes_it() {
|
||||
let mut user = UserKeys::new();
|
||||
assert_eq!(assign(&mut user, "qsave", "Cmd+Shift+K"), Ok(vec![]));
|
||||
assert_eq!(runs(&user, "Cmd+S"), None, "Save's own Cmd+S is replaced");
|
||||
assert_eq!(runs(&user, "Cmd+Shift+K").as_deref(), Some("qsave"));
|
||||
assert_eq!(assign(&mut user, "plot", ""), Ok(vec![]));
|
||||
assert_eq!(runs(&user, "Cmd+P"), None);
|
||||
assert_eq!(shown(&user, "plot", default_key("plot")), None);
|
||||
// OPEN through its UI form: its Cmd+O goes when OPEN gets another key.
|
||||
assert_eq!(assign(&mut user, "open", "F4"), Ok(vec![]));
|
||||
assert_eq!(runs(&user, "Cmd+O"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_shortcuts_and_commands_are_refused() {
|
||||
let mut user = UserKeys::new();
|
||||
assert!(assign(&mut user, "nosuchcommand", "Cmd+L").is_err());
|
||||
assert!(assign(&mut user, "line", "L").is_err());
|
||||
assert!(assign(&mut user, "line", "Cmd+Nothing").is_err());
|
||||
assert!(assign(&mut user, "line", &"Cmd+".repeat(1000)).is_err());
|
||||
assert!(assign(&mut user, "copyclip", "Cmd+K").is_err(), "the clipboard keys stay");
|
||||
assert!(assign(&mut user, "pasteblock", "Cmd+K").is_err(), "Shift+Cmd+V is a clipboard event too");
|
||||
assert!(user.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_sets_resets_and_never_opens_the_dialog() {
|
||||
let mut app = app();
|
||||
let v = app.run("ui.shortcuts", json!({"set": {"LINE": "Cmd+P"}})).unwrap();
|
||||
assert_eq!(v["shortcuts"], json!({"line": "Cmd+P"}));
|
||||
assert_eq!(v["replaced"], json!(["plot"]));
|
||||
assert_eq!(app.ui.dialog, None);
|
||||
assert!(app.run("ui.shortcuts", json!({"set": {"line": 3}})).is_err());
|
||||
assert!(app.run("ui.shortcuts", json!({"set": "Cmd+P"})).is_err());
|
||||
assert_eq!(app.ui.shortcuts.get("line").map(String::as_str), Some("Cmd+P"), "a refused call changes nothing");
|
||||
let v = app.run("ui.shortcuts", json!({"reset": true})).unwrap();
|
||||
assert_eq!(v["shortcuts"], json!({}));
|
||||
app.start("ui.shortcuts");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some(DIALOG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_are_kept_with_the_preferences_and_hostile_ones_dropped() {
|
||||
let mut a = app();
|
||||
let _ = a.run("ui.shortcuts", json!({"set": {"line": "Cmd+L", "plot": ""}}));
|
||||
let mut b = app();
|
||||
b.load_prefs(&a.prefs_json());
|
||||
assert_eq!(b.ui.shortcuts, a.ui.shortcuts);
|
||||
let hostile =
|
||||
json!({"shortcuts": {"line": "L", "nosuch": "Cmd+K", "copyclip": "Cmd+K", "circle": 5, "arc": "Cmd+K", "x".repeat(5000): "Cmd+J"}});
|
||||
let mut c = app();
|
||||
c.load_prefs(&hostile.to_string());
|
||||
assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string())]));
|
||||
let many: serde_json::Map<String, Value> = (0..5000).map(|i| (format!("c{i}"), json!("Cmd+K"))).collect();
|
||||
c.load_prefs(&json!({ "shortcuts": many }).to_string());
|
||||
assert!(c.ui.shortcuts.len() <= MAX_KEYS);
|
||||
c.load_prefs(r#"{"shortcuts": "Cmd+K"}"#);
|
||||
// One key for two commands (an edited file): the first keeps it, the other is dropped.
|
||||
c.load_prefs(r#"{"shortcuts": {"arc": "Cmd+K", "line": "Cmd+K", "circle": "Cmd+J"}}"#);
|
||||
assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string()), ("circle".to_string(), "Cmd+J".to_string())]));
|
||||
}
|
||||
|
||||
/// Window ▸ Reset Palettes puts the palettes back, not the user's shortcuts.
|
||||
#[test]
|
||||
fn reset_palettes_keeps_the_users_shortcuts() {
|
||||
let mut app = app();
|
||||
let _ = app.run("ui.shortcuts", json!({"set": {"line": "Cmd+L"}}));
|
||||
app.run("ui.resetpalettes", json!({})).unwrap();
|
||||
assert_eq!(app.ui.shortcuts, UserKeys::from([("line".to_string(), "Cmd+L".to_string())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menus_show_the_shortcut_in_force() {
|
||||
let mut app = app();
|
||||
let _ = app.run("ui.shortcuts", json!({"set": {"line": "Cmd+P"}}));
|
||||
let mut found = (None, None);
|
||||
fn walk(es: &[crate::menus::Entry], found: &mut (Option<Option<String>>, Option<Option<String>>)) {
|
||||
for e in es {
|
||||
match e {
|
||||
crate::menus::Entry::Item { id, shortcut, .. } if id == "line" => found.0 = Some(shortcut.clone()),
|
||||
crate::menus::Entry::Item { id, shortcut, .. } if id == "plot" => found.1 = Some(shortcut.clone()),
|
||||
crate::menus::Entry::Sub { children, .. } => walk(children, found),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, es) in crate::menus::tree(&app) {
|
||||
walk(&es, &mut found);
|
||||
}
|
||||
assert_eq!(found, (Some(Some("Cmd+P".into())), Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_key_runs_its_command_and_quiets_shortcuts_while_recording() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let _ = app.run("ui.shortcuts", json!({"set": {"line": "F4"}}));
|
||||
let press = |key: Key| egui::RawInput {
|
||||
events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(press(Key::F4), |ui| crate::menus::shortcuts(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(app.session.summary()["running"], json!("line"));
|
||||
app.session.cancel();
|
||||
// Recording in the dialog: F4 becomes the recorded key, nothing runs.
|
||||
app.ui.dialog = Some(DIALOG.into());
|
||||
ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECORDING), "circle".to_string()));
|
||||
let mut out = ctx.run_ui(press(Key::F6), |ui| {
|
||||
crate::menus::shortcuts(&mut app, ui.ctx());
|
||||
let mut open = true;
|
||||
dialog(&mut app, ui.ctx(), &mut open);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(app.session.summary()["running"], Value::Null);
|
||||
assert_eq!(app.ui.shortcuts.get("circle").map(String::as_str), Some("F6"));
|
||||
assert!(!recording(&ctx));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ pub mod gpu;
|
||||
pub mod grip_dyn;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod keys;
|
||||
pub mod layers;
|
||||
pub mod managers;
|
||||
pub mod menus;
|
||||
@@ -119,6 +120,8 @@ pub struct UiState {
|
||||
pub start_tab: bool,
|
||||
pub dialog: Option<String>,
|
||||
pub history_lines: usize,
|
||||
/// The user's keyboard shortcuts ([`keys`]).
|
||||
pub shortcuts: keys::UserKeys,
|
||||
/// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark".
|
||||
pub theme: theme::ThemePref,
|
||||
/// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS
|
||||
@@ -133,6 +136,8 @@ pub struct UiState {
|
||||
pub recent_files: Vec<String>,
|
||||
/// The folder the last APPLOAD file came from: the next pick starts there.
|
||||
pub last_lisp_dir: Option<String>,
|
||||
/// The folder the Command Aliases dialog last imported a `.pgp` file from.
|
||||
pub last_pgp_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// A DXF/DWG file version (2000 … 2018).
|
||||
@@ -205,12 +210,14 @@ impl Default for UiState {
|
||||
start_tab: false,
|
||||
dialog: None,
|
||||
history_lines: 3,
|
||||
shortcuts: keys::UserKeys::new(),
|
||||
theme: theme::ThemePref::default(),
|
||||
animations: animation::AnimationPref::default(),
|
||||
save_format: SaveFormat::default(),
|
||||
save_version: SaveVersion::default().year().to_string(),
|
||||
recent_files: Vec::new(),
|
||||
last_lisp_dir: None,
|
||||
last_pgp_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +234,8 @@ pub struct Services {
|
||||
/// The host has no file system to read a path from (the browser): a dropped file's contents
|
||||
/// are what it loads, and APPLOAD says so instead of asking for a name it cannot read.
|
||||
pub no_file_system: bool,
|
||||
/// Pick a `.pgp` alias file, starting in the folder given (the last import's).
|
||||
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct CadApp {
|
||||
@@ -490,6 +499,12 @@ impl CadApp {
|
||||
if let Some(dir) = &self.ui.last_lisp_dir {
|
||||
prefs["lispDir"] = json!(dir);
|
||||
}
|
||||
if let Some(dir) = self.ui.last_pgp_dir.as_deref().filter(|d| !d.is_empty()) {
|
||||
prefs["lastPgpDir"] = json!(dir);
|
||||
}
|
||||
if !self.ui.shortcuts.is_empty() {
|
||||
prefs["shortcuts"] = json!(self.ui.shortcuts);
|
||||
}
|
||||
prefs.to_string()
|
||||
}
|
||||
|
||||
@@ -523,6 +538,12 @@ impl CadApp {
|
||||
if let Some(dir) = v.get("lispDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
|
||||
self.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
if let Some(dir) = v.get("lastPgpDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
|
||||
self.ui.last_pgp_dir = Some(dir.to_string());
|
||||
}
|
||||
if let Some(keys) = v.get("shortcuts").filter(|k| k.is_object()) {
|
||||
self.ui.shortcuts = keys::from_prefs(keys);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the system cursor the interface default (`on`) and turn it on; saved preferences and
|
||||
|
||||
@@ -134,6 +134,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.cmdline.clear", "Clear Command History", &[], None),
|
||||
("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None),
|
||||
("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")),
|
||||
("ui.shortcuts", "Keyboard Shortcuts...", &["Tools", "Customize", "Keyboard Shortcuts..."], None),
|
||||
("ui.language", "Language", &[], None),
|
||||
("ui.noop", "", &[], None),
|
||||
("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")),
|
||||
@@ -161,6 +162,9 @@ fn route_ui_subsystems(app: &mut CadApp, id: &str, params: &Value) -> Option<Res
|
||||
if let Some(r) = crate::managers::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
if id == "ui.shortcuts" {
|
||||
return Some(crate::keys::command(app, params));
|
||||
}
|
||||
if matches!(id, "ui.dock" | "ui.dock.reset") {
|
||||
return Some(crate::docking::command(app, id == "ui.dock.reset", params));
|
||||
}
|
||||
@@ -490,6 +494,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format);
|
||||
let (animations, save_version) = (app.ui.animations, std::mem::take(&mut app.ui.save_version));
|
||||
let system_cursor = app.system_cursor_default;
|
||||
// The user's shortcuts and import folder are preferences, not palette layout.
|
||||
let (shortcuts, last_pgp_dir) = (std::mem::take(&mut app.ui.shortcuts), app.ui.last_pgp_dir.take());
|
||||
app.ui = crate::UiState {
|
||||
in_window_menu: menu,
|
||||
theme,
|
||||
@@ -498,6 +504,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
save_format,
|
||||
save_version,
|
||||
system_cursor,
|
||||
shortcuts,
|
||||
last_pgp_dir,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(Value::Null)
|
||||
@@ -651,6 +659,7 @@ pub enum Entry {
|
||||
/// The full menu tree from the command registry plus UI commands, in registration order.
|
||||
pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> {
|
||||
let mut out: Vec<(String, Vec<Entry>)> = MENUS.iter().map(|m| (m.to_string(), Vec::new())).collect();
|
||||
let user = &app.ui.shortcuts;
|
||||
let mut add = |path: &[&str], label: &str, id: &str, shortcut: Option<&str>, enabled: bool| {
|
||||
let Some((top, rest)) = path.split_first() else { return };
|
||||
let Some((_, entries)) = out.iter_mut().find(|(m, _)| m == top) else { return };
|
||||
@@ -661,7 +670,7 @@ pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> {
|
||||
cur.push(Entry::Item {
|
||||
label: if seg.is_empty() { label.to_string() } else { seg.to_string() },
|
||||
id: id.to_string(),
|
||||
shortcut: shortcut.map(str::to_string),
|
||||
shortcut: crate::keys::shown(user, id, shortcut),
|
||||
enabled,
|
||||
});
|
||||
} else {
|
||||
@@ -913,33 +922,40 @@ pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)>
|
||||
]
|
||||
}
|
||||
|
||||
/// Keyboard shortcuts and function keys.
|
||||
/// Keyboard shortcuts and function keys: the user's ([`crate::keys`]) and CADCraft's own.
|
||||
pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k);
|
||||
let cmd = Modifiers::COMMAND;
|
||||
let pairs = builtin_shortcuts();
|
||||
// A recording ends with the dialog it was started in; while it lasts the keys are its own.
|
||||
if app.ui.dialog.as_deref() != Some(crate::keys::DIALOG) {
|
||||
crate::keys::stop_recording(ctx);
|
||||
}
|
||||
if crate::keys::recording(ctx) {
|
||||
return;
|
||||
}
|
||||
let pairs = crate::keys::effective_cached(ctx, &app.ui.shortcuts);
|
||||
crate::clipboard::mirror(app, ctx);
|
||||
if app.closing.is_some() {
|
||||
return;
|
||||
}
|
||||
// A palette toggle is global even while its expression field owns keyboard focus.
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&sc(cmd, Key::Num8))) {
|
||||
let quickcalc = KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8);
|
||||
if pairs.iter().any(|(s, id)| *s == quickcalc && id == "ui.toggle.quickcalc") && ctx.input_mut(|i| i.consume_shortcut(&quickcalc)) {
|
||||
run_transparent(app, "ui.toggle.quickcalc");
|
||||
}
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
// Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses.
|
||||
let mut fire = crate::clipboard::shortcut_commands(app, ctx);
|
||||
let mut fire: Vec<String> = crate::clipboard::shortcut_commands(app, ctx).into_iter().map(str::to_string).collect();
|
||||
ctx.input_mut(|i| {
|
||||
for (s, id) in &pairs {
|
||||
if i.consume_shortcut(s) && !fire.contains(id) {
|
||||
fire.push(*id);
|
||||
fire.push(id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
for id in fire {
|
||||
let id = id.as_str();
|
||||
// ISOPLANE (F5, Ctrl+E) switches the plane without cancelling the active command.
|
||||
if id == "isoplane" {
|
||||
if let Some(m) = app.session.execute(id, &json!({})).ok().and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! the text's location and width with a formatting toolbar (font, height, bold, italic,
|
||||
//! underline, overline, colour, alignment, lists, tabs) and a ruler for the paragraph's indents
|
||||
//! and tab stops. The text and its formats live in the engine's
|
||||
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits.
|
||||
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. TEXTEDIT
|
||||
//! (and so a double-click) on an existing MTEXT opens it here with its contents (#842).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -72,6 +73,12 @@ struct State {
|
||||
sel: (usize, usize),
|
||||
/// A caret position to set on the text box in the next frame (after a toolbar insertion).
|
||||
caret: Option<usize>,
|
||||
/// The text box has had keyboard focus: it takes it once when the editor opens, so typing
|
||||
/// goes into the text, not to the command line.
|
||||
focused: bool,
|
||||
/// The MTEXT the state was made for (its insertion point and contents when it opened): a
|
||||
/// state kept for another one is not reused, so one MTEXT's text never goes to the next.
|
||||
target: Option<(cadcraft_engine::geom::Vec3, String)>,
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
@@ -91,7 +98,8 @@ fn color32(c: MTextColor) -> Color32 {
|
||||
Color32::from_rgb(rgb.0, rgb.1, rgb.2)
|
||||
}
|
||||
|
||||
/// Show the editor while MTEXT collects its text; clears its state when it isn't running.
|
||||
/// Show the editor while MTEXT, or TEXTEDIT on an MTEXT, collects its text; clears its state
|
||||
/// when neither is running.
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let mtext = app.session.editing_mtext();
|
||||
let asking = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
|
||||
@@ -101,15 +109,20 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
};
|
||||
let t = Tokens::get();
|
||||
let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6)));
|
||||
let mut st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).unwrap_or_else(|| {
|
||||
let mut fmt = Formatted::default();
|
||||
let target = (mt.insert, mt.contents.clone());
|
||||
let kept = ctx.data_mut(|d| d.get_temp::<State>(state_id())).filter(|st| st.target.as_ref() == Some(&target));
|
||||
let mut st = kept.unwrap_or_else(|| {
|
||||
// An existing MTEXT (TEXTEDIT) starts with its contents and their formats.
|
||||
let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents_with_height(&mt.contents, Some(mt.height)) };
|
||||
if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) {
|
||||
// A family name, not a font file (`txt.shx`, `arial.ttf`).
|
||||
if !f.is_empty() && !f.contains('.') {
|
||||
fmt.base_font = f;
|
||||
}
|
||||
}
|
||||
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }
|
||||
// An existing text opens with the caret at its end.
|
||||
let caret = (!fmt.is_empty()).then(|| fmt.len());
|
||||
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret, focused: false, target: Some(target.clone()) }
|
||||
});
|
||||
let base_h = if mt.height > 0.0 { mt.height } else { 0.2 };
|
||||
let px = ((base_h * scale) as f32).clamp(MIN_PX, MAX_PX);
|
||||
@@ -158,8 +171,15 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
Done::Ok => {
|
||||
ctx.data_mut(|d| d.remove::<State>(state_id()));
|
||||
// The editor holds the whole contents: one input, then Enter ends MTEXT's text entry.
|
||||
if !st.fmt.is_empty() {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents()));
|
||||
// Emptied while editing an existing MTEXT: it keeps its text (Enter), as TEXTEDIT
|
||||
// keeps it for an empty answer, and for one left unchanged (no undo step).
|
||||
let textedit = app.session.running.as_ref().is_some_and(|r| r.id == "textedit");
|
||||
let contents = st.fmt.to_contents();
|
||||
let unchanged = textedit && Formatted::leaves_unchanged(&mt.contents, Some(mt.height), &contents);
|
||||
if !st.fmt.is_empty() && !unchanged {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Text(contents));
|
||||
} else if textedit {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Enter);
|
||||
}
|
||||
if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") {
|
||||
let _ = app.session.input(cadcraft_engine::Input::Enter);
|
||||
@@ -248,7 +268,10 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
|
||||
let mut h = cur.height.unwrap_or(base_h);
|
||||
if height_field(ui, &mut h, base_h) {
|
||||
let none = (h - base_h).abs() < 1e-9;
|
||||
change(st, |c| c.height = (!none).then_some(h));
|
||||
change(st, |c| {
|
||||
c.height = (!none).then_some(h);
|
||||
c.height_rel = None;
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
for (flag, label, tip) in [
|
||||
@@ -507,9 +530,8 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) {
|
||||
/// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens.
|
||||
fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) {
|
||||
let id = edit_id();
|
||||
if let Some(at) = st.caret.take()
|
||||
&& let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), id)
|
||||
{
|
||||
if let Some(at) = st.caret.take() {
|
||||
let mut s = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at))));
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
@@ -568,9 +590,13 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
out.response.response.clone().accessible_name(crate::tl!("Text"));
|
||||
if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() {
|
||||
// On opening, and while the text is empty, the editor takes the focus; a field of the toolbar
|
||||
// the user clicked keeps it.
|
||||
let free = ui.memory(|m| m.focused().is_none());
|
||||
if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty() && free)) {
|
||||
out.response.request_focus();
|
||||
}
|
||||
st.focused = true;
|
||||
if out.response.changed() {
|
||||
let typing = st.typing.clone();
|
||||
st.fmt.set_text(&buf, &typing);
|
||||
@@ -603,7 +629,16 @@ mod tests {
|
||||
/// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget.
|
||||
#[test]
|
||||
fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() {
|
||||
let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
|
||||
let state = State {
|
||||
fmt: Formatted::default(),
|
||||
typing: CharFmt::default(),
|
||||
typing_at: 0,
|
||||
top: 70.0,
|
||||
sel: (0, 0),
|
||||
caret: None,
|
||||
focused: false,
|
||||
target: None,
|
||||
};
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state(
|
||||
|ui, state| {
|
||||
toolbar(ui, state, 2.5);
|
||||
@@ -620,10 +655,48 @@ mod tests {
|
||||
assert!(harness.unnamed_widgets().is_empty());
|
||||
}
|
||||
|
||||
/// With the text empty, a click on the height field keeps the focus there; the editor doesn't
|
||||
/// take it back.
|
||||
#[test]
|
||||
fn an_empty_editor_leaves_the_focus_on_the_height_field() {
|
||||
let state = State {
|
||||
fmt: Formatted::default(),
|
||||
typing: CharFmt::default(),
|
||||
typing_at: 0,
|
||||
top: 70.0,
|
||||
sel: (0, 0),
|
||||
caret: None,
|
||||
focused: false,
|
||||
target: None,
|
||||
};
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).build_ui_state(
|
||||
|ui, state| {
|
||||
toolbar(ui, state, 2.5);
|
||||
text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get());
|
||||
},
|
||||
state,
|
||||
);
|
||||
harness.run();
|
||||
assert!(harness.ctx.memory(|m| m.has_focus(edit_id())), "the editor opens with the focus");
|
||||
harness.get_by_role_and_label(Role::SpinButton, "Text height").click();
|
||||
harness.run();
|
||||
harness.run();
|
||||
assert!(!harness.ctx.memory(|m| m.has_focus(edit_id())), "the height field keeps the focus");
|
||||
}
|
||||
|
||||
/// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed.
|
||||
#[test]
|
||||
fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() {
|
||||
let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
|
||||
let mut state = State {
|
||||
fmt: Formatted::default(),
|
||||
typing: CharFmt::default(),
|
||||
typing_at: 0,
|
||||
top: 70.0,
|
||||
sel: (0, 0),
|
||||
caret: None,
|
||||
focused: false,
|
||||
target: None,
|
||||
};
|
||||
let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5));
|
||||
harness.run();
|
||||
for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] {
|
||||
@@ -712,4 +785,101 @@ mod tests {
|
||||
let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents));
|
||||
assert!(found, "{contents}");
|
||||
}
|
||||
|
||||
/// #842: TEXTEDIT on an existing MTEXT (what a double-click starts) opens it in the editor
|
||||
/// with its text and formats; finishing writes the edited contents back to that MTEXT.
|
||||
/// TEXTEDIT moving on to another MTEXT between two frames (Enter keeps the first, a point picks
|
||||
/// the next) opens the next one with its own text, never the first one's.
|
||||
#[test]
|
||||
fn textedit_on_the_next_mtext_does_not_keep_the_first_ones_text() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let frame = |app: &mut CadApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "first one", "height": 2.5, "width": 20 })).unwrap();
|
||||
app.run("mtext", serde_json::json!({ "at": [0, 60], "text": "second one", "height": 2.5, "width": 20 })).unwrap();
|
||||
app.start("textedit");
|
||||
app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(1.0, 19.0))).unwrap();
|
||||
frame(&mut app);
|
||||
assert_eq!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open").fmt.text(), "first one");
|
||||
// Both inputs between two frames: the first MTEXT kept as it is, then the second picked.
|
||||
app.session.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(1.0, 59.0))).unwrap();
|
||||
assert!(app.session.editing_mtext().is_some_and(|m| m.contents.contains("second")), "TEXTEDIT is on the second MTEXT");
|
||||
frame(&mut app);
|
||||
assert_eq!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open").fmt.text(), "second one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_opens_an_existing_mtext_with_its_contents() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let frame = |app: &mut CadApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "{\\fArial|b1;first}\\Psecond", "height": 2.5, "width": 20 })).unwrap();
|
||||
let h = app.session.doc().unwrap().model.last().unwrap().handle;
|
||||
app.start("textedit");
|
||||
let at = cadcraft_geom::Vec2::new(1.0, 19.0);
|
||||
app.session.input(cadcraft_engine::Input::Point(at)).unwrap();
|
||||
let m = app.session.editing_mtext().expect("an MTEXT to edit");
|
||||
assert!(m.contents.contains("second"), "{}", m.contents);
|
||||
frame(&mut app);
|
||||
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
|
||||
assert_eq!(st.fmt.text(), "first\nsecond");
|
||||
assert!(st.fmt.fmt_at(0).bold && !st.fmt.fmt_at(7).bold, "formats kept");
|
||||
let mut st = st;
|
||||
st.fmt.set_text("first\nsecond\nthird", &CharFmt::default());
|
||||
app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).is_none(), "editor closed");
|
||||
match &app.session.doc().unwrap().entity(h).unwrap().kind {
|
||||
cadcraft_engine::doc::EntityKind::MText(m) => assert!(m.contents.contains("third") && m.contents.contains("b1"), "{}", m.contents),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A multileader's text with a relative height code (`\H2x;`) opens in the editor at twice
|
||||
/// its text height, so saving it back keeps the bigger letters.
|
||||
#[test]
|
||||
fn a_relative_height_in_a_multileaders_text_is_kept() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let frame = |app: &mut CadApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
app.run("mleader", serde_json::json!({ "points": [[16, -10], [20, -5]], "text": "{\\H2x;big} small" })).unwrap();
|
||||
let (at, height) = app
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
if let cadcraft_engine::doc::EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (t.insert.xy(), t.height)) } else { None }
|
||||
})
|
||||
.unwrap();
|
||||
app.start("textedit");
|
||||
app.session.input(cadcraft_engine::Input::Point(at + cadcraft_geom::Vec2::new(0.5, -0.05))).unwrap();
|
||||
assert!(app.session.editing_mtext().is_some(), "the multileader's text is being edited");
|
||||
frame(&mut app);
|
||||
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
|
||||
assert_eq!(st.fmt.text(), "big small");
|
||||
let big = st.fmt.fmt_at(0).height.expect("the relative height is kept");
|
||||
assert!((big - 2.0 * height).abs() < 1e-9, "{big} for a {height} text");
|
||||
assert_eq!(st.fmt.fmt_at(5).height, None, "the rest keeps the text's height");
|
||||
}
|
||||
}
|
||||
|
||||
+165
-13
@@ -16,7 +16,10 @@
|
||||
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
|
||||
//! | `SAVEFILEPATH` | the folder automatic saves go to |
|
||||
//! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `<name>.bak` (default); 0: no backup |
|
||||
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
|
||||
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: `rightClickCommand` decides |
|
||||
//! | `rightClickDuration` | how long the right button is held to open the menu while a command runs, 100–2000 ms (250) |
|
||||
//! | `rightClickDefault`, `rightClickEdit` | a right-click with no command running and nothing (`Default`) or something (`Edit`) selected: `"menu"` (the shortcut menu, default) or `"repeat"` (the last command) |
|
||||
//! | `rightClickCommand` | a right-click while a command runs, with the time-sensitive right-click off: `"menu"` (default), `"enter"`, or `"menuWithOptions"` (the menu when the prompt has options, else Enter) |
|
||||
//! | `systemCursor` | keep the system cursor visible over the drawing area |
|
||||
//! | `historyLines` | command-line history lines, 0–12 |
|
||||
//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default |
|
||||
@@ -27,9 +30,9 @@
|
||||
//! SAVETIME, SAVEFILEPATH and ISAVEBAK are the engine's save settings, kept with the autosave state
|
||||
//! rather than in the profile.
|
||||
//!
|
||||
//! The profile variables and the interface preferences survive restarts: the host saves
|
||||
//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with
|
||||
//! [`CadApp::load_profile`].
|
||||
//! The profile variables, the user's ALIASEDIT aliases and the interface preferences survive
|
||||
//! restarts: the host saves [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface
|
||||
//! prefs and restores it with [`CadApp::load_profile`].
|
||||
|
||||
mod dialog;
|
||||
|
||||
@@ -38,6 +41,8 @@ pub use dialog::dialog;
|
||||
use egui::Color32;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use cadcraft_engine::cmd::file::MAX_ALIAS_PROFILE_BYTES;
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::animation::AnimationPref;
|
||||
use crate::theme::{self, ThemePref};
|
||||
@@ -45,8 +50,14 @@ use crate::theme::{self, ThemePref};
|
||||
/// The host storage key for [`CadApp::profile_json`].
|
||||
pub const PROFILE_KEY: &str = "cadcraft.profile";
|
||||
|
||||
/// The largest saved profile [`CadApp::load_profile`] reads.
|
||||
const MAX_PROFILE_BYTES: usize = 64 * 1024;
|
||||
/// The largest saved profile [`CadApp::load_profile`] reads: everything [`CadApp::profile_json`]
|
||||
/// writes — a full ALIASEDIT alias set (up to [`MAX_ALIAS_PROFILE_BYTES`]) and the profile
|
||||
/// variables, colours and interface preferences — plus the JSON around them. A valid profile is
|
||||
/// never refused; anything larger is corrupt or hostile.
|
||||
pub const MAX_PROFILE_BYTES: usize = MAX_ALIAS_PROFILE_BYTES + PROFILE_OVERHEAD_BYTES;
|
||||
|
||||
/// Room for the profile variables, the interface preferences and the JSON around them.
|
||||
const PROFILE_OVERHEAD_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// The longest SAVEFILEPATH the JSON form accepts.
|
||||
const MAX_PATH_CHARS: usize = 1024;
|
||||
@@ -57,6 +68,66 @@ pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive<f32> = 5.0..=31.0;
|
||||
/// Interface scales the JSON form accepts (1: the system's own size).
|
||||
pub const INTERFACE_SCALES: std::ops::RangeInclusive<f32> = 0.5..=3.0;
|
||||
|
||||
/// How long a right-click may be held before it opens the menu, in milliseconds.
|
||||
pub const RIGHT_CLICK_DURATIONS: std::ops::RangeInclusive<u32> = 100..=2000;
|
||||
|
||||
/// What a right-click does with no command running (Right-Click Customization's Default and Edit
|
||||
/// modes).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum IdleClick {
|
||||
/// Repeat the last command.
|
||||
Repeat,
|
||||
/// The shortcut menu.
|
||||
#[default]
|
||||
Menu,
|
||||
}
|
||||
|
||||
impl IdleClick {
|
||||
pub const ALL: [IdleClick; 2] = [IdleClick::Repeat, IdleClick::Menu];
|
||||
|
||||
/// The JSON name.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
IdleClick::Repeat => "repeat",
|
||||
IdleClick::Menu => "menu",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<IdleClick> {
|
||||
IdleClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a right-click does while a command runs with the time-sensitive right-click off
|
||||
/// (Right-Click Customization's Command mode).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CommandClick {
|
||||
/// Enter.
|
||||
Enter,
|
||||
/// The shortcut menu, always.
|
||||
#[default]
|
||||
Menu,
|
||||
/// The shortcut menu when the prompt has options, else Enter.
|
||||
MenuWithOptions,
|
||||
}
|
||||
|
||||
impl CommandClick {
|
||||
pub const ALL: [CommandClick; 3] = [CommandClick::Enter, CommandClick::Menu, CommandClick::MenuWithOptions];
|
||||
|
||||
/// The JSON name.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CommandClick::Enter => "enter",
|
||||
CommandClick::Menu => "menu",
|
||||
CommandClick::MenuWithOptions => "menuWithOptions",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<CommandClick> {
|
||||
CommandClick::ALL.into_iter().find(|c| c.as_str().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface's own preferences (the drafting ones are engine settings).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct UiOptions {
|
||||
@@ -69,8 +140,16 @@ pub struct UiOptions {
|
||||
/// The selection highlight colour; `None` keeps the theme's.
|
||||
pub selection_color: Option<Color32>,
|
||||
/// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu
|
||||
/// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu.
|
||||
/// (AutoCAD's time-sensitive right-click). Off: [`Self::right_click_command`] decides.
|
||||
pub time_sensitive_right_click: bool,
|
||||
/// How long the right button is held to open the menu while a command runs, in milliseconds.
|
||||
pub right_click_hold_ms: u32,
|
||||
/// A right-click with no command running and nothing selected (Default mode) or a selection
|
||||
/// (Edit mode).
|
||||
pub right_click_default: IdleClick,
|
||||
pub right_click_edit: IdleClick,
|
||||
/// A right-click while a command runs, with the time-sensitive right-click off.
|
||||
pub right_click_command: CommandClick,
|
||||
/// The size of every menu, palette, dialog and the command line (1: the system's own).
|
||||
pub interface_scale: f32,
|
||||
}
|
||||
@@ -83,6 +162,10 @@ impl Default for UiOptions {
|
||||
snap_marker_size: theme::SNAP_MARKER,
|
||||
selection_color: None,
|
||||
time_sensitive_right_click: true,
|
||||
right_click_hold_ms: (crate::context_menu::HOLD_SECS * 1000.0) as u32,
|
||||
right_click_default: IdleClick::Menu,
|
||||
right_click_edit: IdleClick::Menu,
|
||||
right_click_command: CommandClick::Menu,
|
||||
interface_scale: 1.0,
|
||||
}
|
||||
}
|
||||
@@ -150,6 +233,10 @@ struct UiChanges {
|
||||
savefilepath: Option<String>,
|
||||
isavebak: Option<bool>,
|
||||
time_sensitive_right_click: Option<bool>,
|
||||
right_click_hold_ms: Option<u32>,
|
||||
right_click_default: Option<IdleClick>,
|
||||
right_click_edit: Option<IdleClick>,
|
||||
right_click_command: Option<CommandClick>,
|
||||
interface_scale: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -193,6 +280,17 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
|
||||
c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?);
|
||||
}
|
||||
"timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?),
|
||||
"rightClickDuration" => {
|
||||
let (lo, hi) = (f64::from(*RIGHT_CLICK_DURATIONS.start()), f64::from(*RIGHT_CLICK_DURATIONS.end()));
|
||||
let n =
|
||||
v.as_f64().filter(|n| n.is_finite() && (lo..=hi).contains(n)).ok_or("rightClickDuration must be from 100 to 2000 milliseconds")?;
|
||||
c.right_click_hold_ms = Some(n.round() as u32);
|
||||
}
|
||||
"rightClickDefault" => c.right_click_default = Some(IdleClick::parse(text()?).ok_or("rightClickDefault must be menu or repeat")?),
|
||||
"rightClickEdit" => c.right_click_edit = Some(IdleClick::parse(text()?).ok_or("rightClickEdit must be menu or repeat")?),
|
||||
"rightClickCommand" => {
|
||||
c.right_click_command = Some(CommandClick::parse(text()?).ok_or("rightClickCommand must be menu, enter or menuWithOptions")?)
|
||||
}
|
||||
k if k.eq_ignore_ascii_case("SAVETIME") => {
|
||||
let max = cadcraft_engine::autosave::MAX_SAVETIME;
|
||||
let n = v
|
||||
@@ -234,6 +332,10 @@ fn ui_json(app: &CadApp) -> Map<String, Value> {
|
||||
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
|
||||
m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak())));
|
||||
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
|
||||
m.insert("rightClickDuration".into(), json!(o.right_click_hold_ms));
|
||||
m.insert("rightClickDefault".into(), json!(o.right_click_default.as_str()));
|
||||
m.insert("rightClickEdit".into(), json!(o.right_click_edit.as_str()));
|
||||
m.insert("rightClickCommand".into(), json!(o.right_click_command.as_str()));
|
||||
m
|
||||
}
|
||||
|
||||
@@ -310,9 +412,26 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
if let Some(b) = ui.time_sensitive_right_click {
|
||||
app.options.time_sensitive_right_click = b;
|
||||
}
|
||||
apply_right_click(&mut app.options, &ui);
|
||||
Ok(report(app))
|
||||
}
|
||||
|
||||
/// Right-Click Customization's settings (the time-sensitive switch aside) from `c`.
|
||||
fn apply_right_click(o: &mut UiOptions, c: &UiChanges) {
|
||||
if let Some(ms) = c.right_click_hold_ms {
|
||||
o.right_click_hold_ms = ms;
|
||||
}
|
||||
if let Some(m) = c.right_click_default {
|
||||
o.right_click_default = m;
|
||||
}
|
||||
if let Some(m) = c.right_click_edit {
|
||||
o.right_click_edit = m;
|
||||
}
|
||||
if let Some(m) = c.right_click_command {
|
||||
o.right_click_command = m;
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface preferences back to their defaults (the interface language stays).
|
||||
fn reset_ui(app: &mut CadApp) {
|
||||
let d = crate::UiState::default();
|
||||
@@ -327,19 +446,24 @@ fn reset_ui(app: &mut CadApp) {
|
||||
|
||||
impl CadApp {
|
||||
/// The user profile kept across restarts, as JSON for the host's storage ([`PROFILE_KEY`]):
|
||||
/// the engine's profile variables and the interface preferences the interface prefs
|
||||
/// ([`CadApp::prefs_json`]) and the desktop workspace file (panels, command line) do not
|
||||
/// already keep.
|
||||
/// the engine's profile variables, the user's aliases (ALIASEDIT, not drawing data) and the
|
||||
/// interface preferences the interface prefs ([`CadApp::prefs_json`]) and the desktop
|
||||
/// workspace file (panels, command line) do not already keep.
|
||||
pub fn profile_json(&self) -> String {
|
||||
json!({
|
||||
"version": 1,
|
||||
"sysvars": cadcraft_engine::cmd::options::profile(&self.session),
|
||||
"aliases": cadcraft_engine::cmd::file::alias_profile(),
|
||||
"systemCursor": self.ui.system_cursor,
|
||||
"modelBackground": color_json(self.options.model_background),
|
||||
"paperBackground": color_json(self.options.paper_background),
|
||||
"selectionColor": color_json(self.options.selection_color),
|
||||
"snapMarkerSize": self.options.snap_marker_size,
|
||||
"timeSensitiveRightClick": self.options.time_sensitive_right_click,
|
||||
"rightClickDuration": self.options.right_click_hold_ms,
|
||||
"rightClickDefault": self.options.right_click_default.as_str(),
|
||||
"rightClickEdit": self.options.right_click_edit.as_str(),
|
||||
"rightClickCommand": self.options.right_click_command.as_str(),
|
||||
"interfaceScale": self.options.interface_scale,
|
||||
})
|
||||
.to_string()
|
||||
@@ -355,10 +479,25 @@ impl CadApp {
|
||||
if let Some(vars) = v.get("sysvars") {
|
||||
cadcraft_engine::cmd::options::restore(&mut self.session, vars);
|
||||
}
|
||||
// The user's aliases (ALIASEDIT) are settings, not drawing data; a malformed section is
|
||||
// skipped by the engine without touching the aliases already in use.
|
||||
if let Some(aliases) = v.get("aliases") {
|
||||
cadcraft_engine::cmd::file::restore_aliases(aliases);
|
||||
}
|
||||
let mut c = UiChanges::default();
|
||||
for key in
|
||||
["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick", "interfaceScale"]
|
||||
{
|
||||
for key in [
|
||||
"systemCursor",
|
||||
"modelBackground",
|
||||
"paperBackground",
|
||||
"selectionColor",
|
||||
"snapMarkerSize",
|
||||
"timeSensitiveRightClick",
|
||||
"rightClickDuration",
|
||||
"rightClickDefault",
|
||||
"rightClickEdit",
|
||||
"rightClickCommand",
|
||||
"interfaceScale",
|
||||
] {
|
||||
if let Some(value) = v.get(key) {
|
||||
let _ = parse_ui(key, value, &mut c);
|
||||
}
|
||||
@@ -382,6 +521,7 @@ impl CadApp {
|
||||
if let Some(b) = c.time_sensitive_right_click {
|
||||
self.options.time_sensitive_right_click = b;
|
||||
}
|
||||
apply_right_click(&mut self.options, &c);
|
||||
if let Some(n) = c.interface_scale {
|
||||
self.options.interface_scale = n;
|
||||
}
|
||||
@@ -401,4 +541,16 @@ mod tests {
|
||||
assert_eq!(parse_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_command_clicks_parse_their_names() {
|
||||
for c in IdleClick::ALL {
|
||||
assert_eq!(IdleClick::parse(c.as_str()), Some(c));
|
||||
}
|
||||
for c in CommandClick::ALL {
|
||||
assert_eq!(CommandClick::parse(c.as_str()), Some(c));
|
||||
}
|
||||
assert_eq!(IdleClick::parse("enter"), None);
|
||||
assert_eq!(CommandClick::parse("repeat"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,8 +537,9 @@ fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map<String,
|
||||
});
|
||||
}
|
||||
|
||||
/// Preferences tab: interface language, largest-array limit and time-sensitive right-click; edits accumulate in `changes`.
|
||||
/// Preferences tab: interface language, largest-array limit, time-sensitive right-click and the Right-Click Customization window; edits accumulate in `changes`.
|
||||
fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
let mut right_click = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique(RIGHT_CLICK)).unwrap_or(false));
|
||||
grid(ui, "options_preferences", |ui| {
|
||||
let language_caption = ui.label(crate::tl!("Interface language"));
|
||||
let mut lang = app.ui.interface_language;
|
||||
@@ -570,7 +571,95 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
|
||||
if ui.checkbox(&mut timed, crate::tl!("Time-sensitive right-click: a quick click is Enter, holding opens the menu")).changed() {
|
||||
changes.insert("timeSensitiveRightClick".into(), json!(timed));
|
||||
}
|
||||
hint(ui, crate::tl!("Turned off, a right-click while a command runs always opens the shortcut menu."));
|
||||
hint(ui, crate::tl!("Turned off, a right-click while a command runs does what Right-Click Customization sets for Command mode."));
|
||||
if ui.button(crate::tl!("Right-Click Customization...")).clicked() {
|
||||
right_click = true;
|
||||
}
|
||||
if right_click {
|
||||
right_click_window(&app.options, ui.ctx(), &mut right_click, changes);
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(RIGHT_CLICK), right_click));
|
||||
}
|
||||
|
||||
/// Whether the Right-Click Customization window is open (egui temp data).
|
||||
const RIGHT_CLICK: &str = "cc_options_right_click";
|
||||
|
||||
/// Right-Click Customization (Options ▸ User Preferences): the time-sensitive right-click and its
|
||||
/// duration, and what a right-click does in Default, Edit and Command modes. Changes apply at
|
||||
/// once, as on the Options tabs.
|
||||
fn right_click_window(o: &super::UiOptions, ctx: &egui::Context, open: &mut bool, changes: &mut Map<String, Value>) {
|
||||
use super::{CommandClick, IdleClick};
|
||||
let mut close = false;
|
||||
egui::Window::new(crate::tl!("Right-Click Customization"))
|
||||
.id(egui::Id::unique("cc_right_click"))
|
||||
.open(open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
let mut timed = o.time_sensitive_right_click;
|
||||
if ui.checkbox(&mut timed, crate::tl!("Turn on time-sensitive right-click")).changed() {
|
||||
changes.insert("timeSensitiveRightClick".into(), json!(timed));
|
||||
}
|
||||
ui.add_enabled_ui(o.time_sensitive_right_click, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(20.0);
|
||||
let caption = ui.label(crate::tl!("Longer click duration:"));
|
||||
let mut ms = o.right_click_hold_ms;
|
||||
let range = super::RIGHT_CLICK_DURATIONS;
|
||||
if ui.add(egui::DragValue::new(&mut ms).range(range).speed(10.0)).labelled_by(caption.id).changed() {
|
||||
changes.insert("rightClickDuration".into(), json!(ms));
|
||||
}
|
||||
ui.label(crate::tl!("milliseconds"));
|
||||
});
|
||||
hint(ui, crate::tl!("While a command runs, a quick click is Enter and a longer click opens the shortcut menu."));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
for (title, note, key, current) in [
|
||||
(crate::tl!("Default Mode"), crate::tl!("No objects selected and no command running:"), "rightClickDefault", o.right_click_default),
|
||||
(crate::tl!("Edit Mode"), crate::tl!("Objects selected and no command running:"), "rightClickEdit", o.right_click_edit),
|
||||
] {
|
||||
heading(ui, title);
|
||||
ui.group(|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
hint(ui, note);
|
||||
for (choice, label) in [(IdleClick::Repeat, crate::tl!("Repeat last command")), (IdleClick::Menu, crate::tl!("Shortcut Menu"))] {
|
||||
if ui.radio(current == choice, label).clicked() && current != choice {
|
||||
changes.insert(key.into(), json!(choice.as_str()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_enabled_ui(!o.time_sensitive_right_click, |ui| {
|
||||
heading(ui, crate::tl!("Command Mode"));
|
||||
ui.group(|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
hint(ui, crate::tl!("While a command runs:"));
|
||||
for (choice, label) in [
|
||||
(CommandClick::Enter, crate::tl!("ENTER")),
|
||||
(CommandClick::Menu, crate::tl!("Shortcut Menu: always enabled")),
|
||||
(CommandClick::MenuWithOptions, crate::tl!("Shortcut Menu: enabled when command options are present")),
|
||||
] {
|
||||
if ui.radio(o.right_click_command == choice, label).clicked() && o.right_click_command != choice {
|
||||
changes.insert("rightClickCommand".into(), json!(choice.as_str()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(crate::tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if close {
|
||||
*open = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
|
||||
+642
-64
@@ -705,6 +705,85 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the palette shows for a property that differs between the selected objects.
|
||||
const VARIES: &str = "*VARIES*";
|
||||
|
||||
/// What the palette works out from the selection, for one drawing revision and one selection.
|
||||
#[derive(Default)]
|
||||
struct SelectionValues {
|
||||
key: u64,
|
||||
ids: std::sync::Arc<Vec<String>>,
|
||||
values: std::collections::HashMap<&'static str, Box<dyn std::any::Any + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// The shared handle to a [`SelectionValues`], kept in `ui.data` between frames.
|
||||
#[derive(Clone, Default)]
|
||||
struct SelectionMemo(std::sync::Arc<std::sync::Mutex<SelectionValues>>);
|
||||
|
||||
impl SelectionMemo {
|
||||
/// The value kept under `key`, worked out by `f` the first time.
|
||||
fn get<T: Clone + Send + Sync + 'static>(&self, key: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let mut m = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(v) = m.values.get(key).and_then(|v| v.downcast_ref::<T>()) {
|
||||
return v.clone();
|
||||
}
|
||||
let v = f();
|
||||
m.values.insert(key, Box::new(v.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
/// The selected handles as hex strings, as `properties.set` takes them.
|
||||
fn ids(&self) -> std::sync::Arc<Vec<String>> {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).ids.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The memo for the current drawing revision and selection; `extra` holds whatever else the shown
|
||||
/// values depend on (units, angle settings).
|
||||
fn selection_memo(ui: &egui::Ui, app: &CadApp, sel: &[cadcraft_engine::doc::Handle], extra: impl std::hash::Hash) -> SelectionMemo {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let fresh = || {
|
||||
let ids = sel.iter().map(|h| h.hex()).collect();
|
||||
SelectionMemo(std::sync::Arc::new(std::sync::Mutex::new(SelectionValues { ids: std::sync::Arc::new(ids), ..Default::default() })))
|
||||
};
|
||||
let Ok(st) = app.session.state() else { return fresh() };
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
(st.uid, st.revision, extra, sel).hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
let id = egui::Id::unique("properties-selection-memo");
|
||||
if let Some(memo) = ui.data(|d| d.get_temp::<SelectionMemo>(id))
|
||||
&& memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key == key
|
||||
{
|
||||
return memo;
|
||||
}
|
||||
let memo = fresh();
|
||||
memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key = key;
|
||||
ui.data_mut(|d| d.insert_temp(id, memo.clone()));
|
||||
memo
|
||||
}
|
||||
|
||||
/// Separates what a numeric row shows from the value it was worked out from, so that two objects
|
||||
/// whose values differ by less than the display shows still count as different. The value is
|
||||
/// compared to 12 significant digits, which keeps float noise (a length that comes out as
|
||||
/// 5.000000000000001) from reading as a difference.
|
||||
const EXACT_SEP: char = '\u{1f}';
|
||||
|
||||
/// `shown`, tagged with the number `v` it was formatted from. Round-off next to zero (a Delta Y
|
||||
/// of 1.2e-16 on a level line) is tagged as zero, since 12 significant digits of it are noise.
|
||||
fn exact(shown: String, v: f64) -> String {
|
||||
let v = if v.abs() < 1e-12 { 0.0 } else { v };
|
||||
format!("{shown}{EXACT_SEP}{:.11e}", v + 0.0)
|
||||
}
|
||||
|
||||
/// What a row shows of a value that [`exact`] tagged (a plain string is shown as it is, also one
|
||||
/// that holds the separator: only a tail that reads as the tagged number is taken off).
|
||||
fn shown_part(s: &str) -> String {
|
||||
match s.rsplit_once(EXACT_SEP) {
|
||||
Some((shown, tag)) if tag.parse::<f64>().is_ok() => shown.to_string(),
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands.
|
||||
pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
@@ -716,6 +795,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// Lengths are shown as the drawing's units say (UNITS), and read back in any format.
|
||||
let (lu, lp) = (h.i64("LUNITS", 2), h.i64("LUPREC", 4));
|
||||
let len = |v: f64| cadcraft_engine::units::format_distance(v, lu, lp);
|
||||
let lenx = |v: f64| exact(len(v), v);
|
||||
let mut bad: Option<String> = None;
|
||||
if sel.is_empty() {
|
||||
// Current properties for new objects; each row runs the command that sets it.
|
||||
@@ -870,33 +950,40 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// Selected objects: common + geometry for a single object.
|
||||
let first = sel.first().and_then(|h| d.entity(*h)).map(|e| (**e).clone());
|
||||
let Some(e) = first else { return };
|
||||
let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {
|
||||
let vals: Vec<String> = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect();
|
||||
match vals.first() {
|
||||
Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(),
|
||||
_ => "*VARIES*".into(),
|
||||
}
|
||||
// What the palette works out from the selection is kept while the drawing and the selection
|
||||
// stay as they are: with thousands of objects selected, redoing it every frame is slow.
|
||||
let memo =
|
||||
selection_memo(ui, app, &sel, (lu, lp, app.session.angle_settings().shown(0.0).to_bits(), app.session.angle_settings().shown(1.0).to_bits()));
|
||||
let same = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String {
|
||||
memo.get(key, || {
|
||||
let vals: Vec<String> = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect();
|
||||
match vals.first() {
|
||||
Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(),
|
||||
_ => VARIES.into(),
|
||||
}
|
||||
})
|
||||
};
|
||||
let selected_color = Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c));
|
||||
let color = selected_color.map_or_else(|| "*VARIES*".into(), Color::name);
|
||||
let selected_color = memo.get("color", || Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c)));
|
||||
let color = selected_color.map_or_else(|| VARIES.into(), Color::name);
|
||||
let effective_color = |e: &cadcraft_doc::Entity| {
|
||||
let layer = d.layer(&e.common.layer).map_or(Color::WHITE, |l| l.color);
|
||||
e.common.color.resolve(layer, Color::WHITE)
|
||||
};
|
||||
let resolved_color = sel
|
||||
.first()
|
||||
.and_then(|h| d.entity(*h))
|
||||
.map(|e| effective_color(e))
|
||||
.filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c))
|
||||
.unwrap_or(Rgb(255, 255, 255));
|
||||
let layer = same(&|e| e.common.layer.clone());
|
||||
let linetype = same(&|e| e.common.linetype.clone());
|
||||
let lts = same(&|e| format!("{:.4}", e.common.ltscale));
|
||||
let lw = same(&|e| e.common.lineweight.name());
|
||||
let tr = same(&|e| e.common.transparency.name());
|
||||
let resolved_color = memo.get("resolved color", || {
|
||||
sel.first()
|
||||
.and_then(|h| d.entity(*h))
|
||||
.map(|e| effective_color(e))
|
||||
.filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c))
|
||||
.unwrap_or(Rgb(255, 255, 255))
|
||||
});
|
||||
let layer = same("layer", &|e| e.common.layer.clone());
|
||||
let linetype = same("linetype", &|e| e.common.linetype.clone());
|
||||
let lts = same("ltscale", &|e| format!("{:.4}", e.common.ltscale));
|
||||
let lw = same("lineweight", &|e| e.common.lineweight.name());
|
||||
let tr = same("transparency", &|e| e.common.transparency.name());
|
||||
let layers: Vec<String> = d.layers.iter().map(|l| l.name.clone()).collect();
|
||||
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
|
||||
let ids: Vec<String> = sel.iter().map(|h| h.hex()).collect();
|
||||
let ids = &*memo.ids();
|
||||
let mut set: Option<Value> = None;
|
||||
let mut custom_set: Option<Value> = None;
|
||||
let group = |ui: &mut egui::Ui, title: &str| {
|
||||
@@ -909,7 +996,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let width = (ui.available_width() - 10.0).max(0.0);
|
||||
if let Some(c) = crate::layers::color_button_resolved(
|
||||
ui,
|
||||
egui::Id::unique(("properties_color", &ids)),
|
||||
egui::Id::unique(("properties_color", ids)),
|
||||
selected_color.unwrap_or(Color::ByLayer),
|
||||
resolved_color,
|
||||
&color,
|
||||
@@ -945,7 +1032,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
prop_row(ui, "Linetype scale", |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", ids)), lts.clone()), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, "ltscale": f }));
|
||||
}
|
||||
});
|
||||
@@ -975,7 +1062,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
// A fixed percentage, committed when the drag or edit ends.
|
||||
let tid = egui::Id::unique(("prop_tr", &ids));
|
||||
let tid = egui::Id::unique(("prop_tr", ids));
|
||||
let cur = tr.parse::<u8>().ok();
|
||||
let mut pct = ui.data_mut(|d| d.get_temp::<u8>(tid)).or(cur).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -993,37 +1080,38 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
});
|
||||
let real = |v: f64| format!("{v:.4}");
|
||||
let realx = |v: f64| exact(real(v), v);
|
||||
// Rotations show as the drawing measures directions (ANGBASE, ANGDIR).
|
||||
let angles = app.session.angle_settings();
|
||||
let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
|
||||
prop_row(ui, label, |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) {
|
||||
*set = Some(json!({ "handles": ids, key: f }));
|
||||
}
|
||||
});
|
||||
};
|
||||
if sel.len() == 1 {
|
||||
group(ui, "Geometry");
|
||||
let real = |v: f64| format!("{v:.4}");
|
||||
// Rotations show as the drawing measures directions (ANGBASE, ANGDIR).
|
||||
let angles = app.session.angle_settings();
|
||||
let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
|
||||
prop_row(ui, label, |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) {
|
||||
*set = Some(json!({ "handles": ids, key: f }));
|
||||
}
|
||||
});
|
||||
};
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
prop_row(ui, "Start X", |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", ids)), len(l.a.x)), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, "start": [f, l.a.y] }));
|
||||
}
|
||||
});
|
||||
prop_row(ui, "Start Y", |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", ids)), len(l.a.y)), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, "start": [l.a.x, f] }));
|
||||
}
|
||||
});
|
||||
prop_row(ui, "End X", |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", ids)), len(l.b.x)), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, "end": [f, l.b.y] }));
|
||||
}
|
||||
});
|
||||
prop_row(ui, "End Y", |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", ids)), len(l.b.y)), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, "end": [l.b.x, f] }));
|
||||
}
|
||||
});
|
||||
@@ -1031,16 +1119,16 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
prop_row(ui, "Delta X", |ui| read_only(ui, &len(dlt.x)));
|
||||
prop_row(ui, "Delta Y", |ui| read_only(ui, &len(dlt.y)));
|
||||
// Length and angle keep the start point and move the end point.
|
||||
num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, ids);
|
||||
num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, &ids);
|
||||
num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, &ids);
|
||||
num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, &ids);
|
||||
num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, &ids);
|
||||
num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, &ids);
|
||||
num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, &ids);
|
||||
num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, ids);
|
||||
num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, ids);
|
||||
num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, ids);
|
||||
num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, ids);
|
||||
num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, ids);
|
||||
num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, ids);
|
||||
if let Some(v) = &mut set
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
@@ -1053,31 +1141,42 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, &ids);
|
||||
num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, ids);
|
||||
num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, ids);
|
||||
num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::Text(tx) => {
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), tx.value.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, &ids);
|
||||
num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, &ids);
|
||||
num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, ids);
|
||||
num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, ids);
|
||||
num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::MText(tx) => {
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), tx.contents.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);
|
||||
num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, &ids);
|
||||
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids);
|
||||
num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, ids);
|
||||
}
|
||||
// A multileader's text (#1081): it had no field, so it couldn't be changed.
|
||||
EntityKind::MLeader(ml) => {
|
||||
if let Some(tx) = &ml.text {
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("mltxt", &ids)), tx.contents.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids);
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, &ids);
|
||||
num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, ids);
|
||||
let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
|
||||
prop_row(ui, "Length", |ui| read_only(ui, &len(g.len())));
|
||||
if pl.closed {
|
||||
@@ -1100,12 +1199,12 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, &ids);
|
||||
num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, ids);
|
||||
num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
prop_row(ui, "Text override", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), dm.text.clone()) {
|
||||
set = Some(json!({ "handles": ids, "textOverride": s }));
|
||||
}
|
||||
});
|
||||
@@ -1117,9 +1216,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// The style's values with this dimension's own overrides; editing one overrides
|
||||
// it for the selected dimensions, as AutoCAD's Properties palette does.
|
||||
let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, &ids);
|
||||
num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, &ids);
|
||||
num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, &ids);
|
||||
num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, ids);
|
||||
num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, ids);
|
||||
num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, ids);
|
||||
prop_row(ui, "Precision", |ui| {
|
||||
let cur = st.decimals.to_string();
|
||||
if let Some(n) = pick_menu(ui, &cur, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::<u8>().ok()) {
|
||||
@@ -1136,8 +1235,8 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
prop_row(ui, "Position X", |ui| read_only(ui, &len(i.insert.x)));
|
||||
prop_row(ui, "Position Y", |ui| read_only(ui, &len(i.insert.y)));
|
||||
prop_row(ui, "Position Z", |ui| read_only(ui, &len(i.insert.z)));
|
||||
num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, &ids);
|
||||
num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, &ids);
|
||||
num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, ids);
|
||||
num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, ids);
|
||||
if let Some((_, props)) = custom.filter(|(_, p)| !p.is_empty()) {
|
||||
group(ui, "Custom");
|
||||
for p in &props {
|
||||
@@ -1159,6 +1258,230 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(dims) = sel
|
||||
.iter()
|
||||
.map(|h| match d.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Dimension(dm)) => Some(dm),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
{
|
||||
// Several dimensions: every property of one dimension, `*VARIES*` where they differ; an
|
||||
// edit applies to all of them.
|
||||
group(ui, "Geometry");
|
||||
let shared = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Dimension, &cadcraft_doc::DimStyle) -> String| -> String {
|
||||
memo.get(key, || {
|
||||
let vals: Vec<String> = dims
|
||||
.iter()
|
||||
.map(|dm| {
|
||||
let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
f(dm, &st)
|
||||
})
|
||||
.collect();
|
||||
match vals.first() {
|
||||
Some(v0) if vals.iter().all(|v| v == v0) => shown_part(v0),
|
||||
_ => VARIES.into(),
|
||||
}
|
||||
})
|
||||
};
|
||||
let text = shared("dim.text", &|dm, _| dm.text.clone());
|
||||
prop_row(ui, "Text override", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), text.clone()) {
|
||||
set = Some(json!({ "handles": ids, "textOverride": s }));
|
||||
}
|
||||
});
|
||||
let style = shared("dim.style", &|dm, _| dm.style.clone());
|
||||
prop_row(ui, "Dim style", |ui| {
|
||||
if let Some(n) = pick_menu(ui, &style, d.dim_styles.iter().map(|st| st.name.clone())) {
|
||||
set = Some(json!({ "handles": ids, "dimStyle": n }));
|
||||
}
|
||||
});
|
||||
let numbers = [
|
||||
("Text height", "textHeight", shared("dim.text_height", &|_, st| lenx(st.text_height))),
|
||||
("Arrow size", "arrowSize", shared("dim.arrow_size", &|_, st| lenx(st.arrow_size))),
|
||||
("Dim scale overall", "dimScale", shared("dim.scale", &|_, st| realx(st.scale))),
|
||||
];
|
||||
for (label, key, shown) in numbers {
|
||||
prop_row(ui, label, |ui| {
|
||||
if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), &mut bad) {
|
||||
set = Some(json!({ "handles": ids, key: f }));
|
||||
}
|
||||
});
|
||||
}
|
||||
let precision = shared("dim.precision", &|_, st| st.decimals.to_string());
|
||||
prop_row(ui, "Precision", |ui| {
|
||||
if let Some(n) = pick_menu(ui, &precision, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::<u8>().ok()) {
|
||||
set = Some(json!({ "handles": ids, "precision": n }));
|
||||
}
|
||||
});
|
||||
} else if sel.iter().filter_map(|h| d.entity(*h)).all(|x| std::mem::discriminant(&x.kind) == std::mem::discriminant(&e.kind)) {
|
||||
// Several objects of one type: the properties of one object, `*VARIES*` where they differ.
|
||||
// Edits apply to all of them; positions are shown but stay read-only, since typing one
|
||||
// would move every object to the same place.
|
||||
group(ui, "Geometry");
|
||||
let shared = |key: &'static str, f: &dyn Fn(&EntityKind) -> Option<String>| -> String {
|
||||
memo.get(key, || {
|
||||
let vals: Vec<Option<String>> = sel.iter().filter_map(|h| d.entity(*h)).map(|x| f(&x.kind)).collect();
|
||||
match vals.first() {
|
||||
Some(Some(v0)) if vals.iter().all(|v| v.as_ref() == Some(v0)) => shown_part(v0),
|
||||
_ => VARIES.into(),
|
||||
}
|
||||
})
|
||||
};
|
||||
let deg = |v: f64| real(angles.shown(v).to_degrees());
|
||||
let degx = |v: f64| exact(deg(v), angles.shown(v).to_degrees());
|
||||
match &e.kind {
|
||||
EntityKind::Line(_) => {
|
||||
let line = |k: &EntityKind| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None };
|
||||
for (label, value) in [
|
||||
("Start X", shared("line.ax", &|k| line(k).map(|l| lenx(l.a.x)))),
|
||||
("Start Y", shared("line.ay", &|k| line(k).map(|l| lenx(l.a.y)))),
|
||||
("End X", shared("line.bx", &|k| line(k).map(|l| lenx(l.b.x)))),
|
||||
("End Y", shared("line.by", &|k| line(k).map(|l| lenx(l.b.y)))),
|
||||
("Delta X", shared("line.dx", &|k| line(k).map(|l| lenx(l.b.x - l.a.x)))),
|
||||
("Delta Y", shared("line.dy", &|k| line(k).map(|l| lenx(l.b.y - l.a.y)))),
|
||||
] {
|
||||
prop_row(ui, label, |ui| read_only(ui, &value));
|
||||
}
|
||||
num(ui, "Length", "length", shared("line.length", &|k| line(k).map(|l| lenx((l.b.xy() - l.a.xy()).len()))), &mut set, &mut bad, ids);
|
||||
num(
|
||||
ui,
|
||||
"Angle",
|
||||
"angle",
|
||||
shared("line.angle", &|k| line(k).map(|l| realx((l.b.xy() - l.a.xy()).angle().to_degrees()))),
|
||||
&mut set,
|
||||
&mut bad,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
EntityKind::Circle(_) => {
|
||||
let circle = |k: &EntityKind| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None };
|
||||
prop_row(ui, "Center X", |ui| read_only(ui, &shared("circle.cx", &|k| circle(k).map(|c| lenx(c.center.x)))));
|
||||
prop_row(ui, "Center Y", |ui| read_only(ui, &shared("circle.cy", &|k| circle(k).map(|c| lenx(c.center.y)))));
|
||||
num(ui, "Radius", "radius", shared("circle.radius", &|k| circle(k).map(|c| lenx(c.radius))), &mut set, &mut bad, ids);
|
||||
num(ui, "Diameter", "diameter", shared("circle.diameter", &|k| circle(k).map(|c| lenx(c.radius * 2.0))), &mut set, &mut bad, ids);
|
||||
num(
|
||||
ui,
|
||||
"Circumference",
|
||||
"circumference",
|
||||
shared("circle.circumference", &|k| circle(k).map(|c| lenx(c.radius * std::f64::consts::TAU))),
|
||||
&mut set,
|
||||
&mut bad,
|
||||
ids,
|
||||
);
|
||||
num(
|
||||
ui,
|
||||
"Area",
|
||||
"area",
|
||||
shared("circle.area", &|k| circle(k).map(|c| realx(c.radius * c.radius * std::f64::consts::PI))),
|
||||
&mut set,
|
||||
&mut bad,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
EntityKind::Arc(_) => {
|
||||
let arc = |k: &EntityKind| if let EntityKind::Arc(a) = k { Some(a.clone()) } else { None };
|
||||
num(ui, "Radius", "radius", shared("arc.radius", &|k| arc(k).map(|a| lenx(a.radius))), &mut set, &mut bad, ids);
|
||||
num(ui, "Start angle", "startAngle", shared("arc.start", &|k| arc(k).map(|a| realx(a.start.to_degrees()))), &mut set, &mut bad, ids);
|
||||
num(ui, "End angle", "endAngle", shared("arc.end", &|k| arc(k).map(|a| realx(a.end.to_degrees()))), &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::Text(_) => {
|
||||
let contents = shared("text.value", &|k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), contents.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
let height = shared("text.height", &|k| if let EntityKind::Text(t) = k { Some(lenx(t.height)) } else { None });
|
||||
num(ui, "Height", "height", height, &mut set, &mut bad, ids);
|
||||
let rotation = shared("text.rotation", &|k| if let EntityKind::Text(t) = k { Some(degx(t.rotation)) } else { None });
|
||||
num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids);
|
||||
let width_factor = shared("text.width_factor", &|k| if let EntityKind::Text(t) = k { Some(realx(t.width_factor)) } else { None });
|
||||
num(ui, "Width factor", "widthFactor", width_factor, &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::MText(_) => {
|
||||
let contents = shared("mtext.contents", &|k| if let EntityKind::MText(t) = k { Some(t.contents.clone()) } else { None });
|
||||
prop_row(ui, "Contents", |ui| {
|
||||
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), contents.clone()) {
|
||||
set = Some(json!({ "handles": ids, "text": s }));
|
||||
}
|
||||
});
|
||||
let height = shared("mtext.height", &|k| if let EntityKind::MText(t) = k { Some(lenx(t.height)) } else { None });
|
||||
num(ui, "Text height", "height", height, &mut set, &mut bad, ids);
|
||||
let width = shared("mtext.width", &|k| if let EntityKind::MText(t) = k { Some(lenx(t.width)) } else { None });
|
||||
num(ui, "Defined width", "width", width, &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::LwPolyline(_) => {
|
||||
// Only Length and Area need the vertices; the other rows read one field.
|
||||
let geom = |p: &cadcraft_doc::LwPolyline| cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
let width = shared("poly.width", &|k| if let EntityKind::LwPolyline(p) = k { Some(lenx(p.const_width)) } else { None });
|
||||
num(ui, "Global width", "width", width, &mut set, &mut bad, ids);
|
||||
let length = shared("poly.length", &|k| if let EntityKind::LwPolyline(p) = k { Some(lenx(geom(p).len())) } else { None });
|
||||
prop_row(ui, "Length", |ui| read_only(ui, &length));
|
||||
let area = shared("poly.area", &|k| {
|
||||
if let EntityKind::LwPolyline(p) = k { Some(if p.closed { real(geom(p).area().abs()) } else { String::new() }) } else { None }
|
||||
});
|
||||
prop_row(ui, "Area", |ui| read_only(ui, &area));
|
||||
let closed = shared("poly.closed", &|k| {
|
||||
if let EntityKind::LwPolyline(p) = k { Some(if p.closed { "Yes" } else { "No" }.to_string()) } else { None }
|
||||
});
|
||||
// Kept untranslated, so a change of language does not show stale text.
|
||||
let closed = if closed == VARIES { closed } else { crate::i18n::t(&closed).to_string() };
|
||||
prop_row(ui, "Closed", |ui| {
|
||||
if let Some(n) = pick_menu(ui, &closed, ["Yes", "No"].map(|s| crate::i18n::t(s).to_string())) {
|
||||
set = Some(json!({ "handles": ids, "closed": n == crate::i18n::t("Yes") }));
|
||||
}
|
||||
});
|
||||
}
|
||||
EntityKind::Hatch(_) => {
|
||||
let pattern = shared("hatch.pattern", &|k| if let EntityKind::Hatch(h) = k { Some(h.pattern.clone()) } else { None });
|
||||
prop_row(ui, "Pattern", |ui| {
|
||||
ui.menu_button(pattern.clone(), |ui| {
|
||||
if let Some(name) = patterns::menu(ui, &pattern) {
|
||||
set = Some(json!({ "handles": ids, "pattern": name }));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
let scale = shared("hatch.scale", &|k| if let EntityKind::Hatch(h) = k { Some(realx(h.scale)) } else { None });
|
||||
num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids);
|
||||
let angle = shared("hatch.angle", &|k| if let EntityKind::Hatch(h) = k { Some(realx(h.angle.to_degrees())) } else { None });
|
||||
num(ui, "Angle", "angle", angle, &mut set, &mut bad, ids);
|
||||
}
|
||||
EntityKind::Insert(_) => {
|
||||
// A dynamic block reference shows its dynamic block's name, as with one reference;
|
||||
// its anonymous blocks (`*U12`) differ from one reference to the next.
|
||||
let name = memo.get("insert.name", || {
|
||||
let names: Vec<String> = sel
|
||||
.iter()
|
||||
.filter_map(|h| match d.entity(*h).map(|x| &x.kind) {
|
||||
Some(EntityKind::Insert(i)) => Some(custom_properties(ui, app, *h).map_or_else(|| i.block.clone(), |(n, _)| n)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
match names.first() {
|
||||
Some(n0) if names.iter().all(|n| n == n0) => n0.clone(),
|
||||
_ => VARIES.to_string(),
|
||||
}
|
||||
});
|
||||
let position = |key: &'static str, f: fn(&cadcraft_doc::Insert) -> f64| {
|
||||
shared(key, &|k| if let EntityKind::Insert(i) = k { Some(len(f(i))) } else { None })
|
||||
};
|
||||
for (label, value) in [
|
||||
("Name", name),
|
||||
("Position X", position("insert.x", |i| i.insert.x)),
|
||||
("Position Y", position("insert.y", |i| i.insert.y)),
|
||||
("Position Z", position("insert.z", |i| i.insert.z)),
|
||||
] {
|
||||
prop_row(ui, label, |ui| read_only(ui, &value));
|
||||
}
|
||||
let rotation = shared("insert.rotation", &|k| if let EntityKind::Insert(i) = k { Some(degx(i.rotation)) } else { None });
|
||||
num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids);
|
||||
let scale = shared("insert.scale", &|k| if let EntityKind::Insert(i) = k { Some(realx(i.scale.x)) } else { None });
|
||||
num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(mut p) = set {
|
||||
// A rotation is typed as shown (from ANGBASE, in the ANGDIR sense); properties.set takes it
|
||||
@@ -1182,6 +1505,15 @@ mod tests {
|
||||
use crate::Services;
|
||||
use cadcraft_engine::Session;
|
||||
use egui::{Color32, Shape};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
#[test]
|
||||
fn shown_part_takes_off_only_the_number_tag() {
|
||||
assert_eq!(shown_part(&exact("5.00".into(), 5.0)), "5.00");
|
||||
assert_eq!(shown_part("plain"), "plain");
|
||||
assert_eq!(shown_part("a\u{1f}b"), "a\u{1f}b");
|
||||
assert_eq!(shown_part(&format!("a{EXACT_SEP}b")), "a\u{1f}b");
|
||||
}
|
||||
|
||||
fn contains_swatch(shape: &Shape, color: Color32) -> bool {
|
||||
match shape {
|
||||
@@ -1209,4 +1541,250 @@ mod tests {
|
||||
}
|
||||
assert!(painted_blue, "a ByLayer entity on a blue layer needs a blue Properties swatch");
|
||||
}
|
||||
|
||||
/// The texts of a painted shape, one string per text.
|
||||
fn collect_text(shape: &Shape, out: &mut Vec<String>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the Properties palette paints for the current selection, one string per text.
|
||||
fn painted_texts(app: &mut CadApp, section: fn(&mut CadApp, &mut egui::Ui)) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() };
|
||||
let mut texts = Vec::new();
|
||||
for _ in 0..2 {
|
||||
app.logic(&ctx);
|
||||
let mut output = ctx.run_ui(input.clone(), |ui| section(app, ui));
|
||||
texts.clear();
|
||||
output.shapes.iter().for_each(|s| collect_text(&s.shape, &mut texts));
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// Several dimensions selected: the Properties palette shows all of a dimension's properties,
|
||||
/// `*VARIES*` for those that differ, and an edit applies to every dimension.
|
||||
#[test]
|
||||
fn several_dimensions_show_every_property_with_varies_for_different_ones() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let first = app.run("dimlinear", json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 3] })).unwrap()["handle"].as_str().unwrap().to_string();
|
||||
let second =
|
||||
app.run("dimlinear", json!({ "p1": [0, 0], "p2": [20, 0], "at": [5, 6], "text": "X" })).unwrap()["handle"].as_str().unwrap().to_string();
|
||||
app.run("select", json!({ "handles": [first, second] })).unwrap();
|
||||
|
||||
let texts = painted_texts(&mut app, properties_section);
|
||||
for want in ["Text override", "Dim style", "Text height", "Arrow size", "Dim scale overall", "Precision"] {
|
||||
assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "the palette shows `{want}`; painted: {texts:?}");
|
||||
}
|
||||
assert!(texts.iter().any(|t| t == VARIES), "the differing text override shows {VARIES}; painted: {texts:?}");
|
||||
|
||||
app.run("properties.set", json!({ "handles": [first, second], "textHeight": 7 })).unwrap();
|
||||
let texts = painted_texts(&mut app, properties_section);
|
||||
assert!(texts.iter().any(|t| t == "7.0000" || t == "7"), "the common text height is shown; painted: {texts:?}");
|
||||
}
|
||||
|
||||
/// Values the display rounds alike still count as different: two dimensions whose text heights
|
||||
/// differ by a millionth show `*VARIES*`, as do two circles whose radii do. Float noise (a
|
||||
/// ten-trillionth) is not a difference.
|
||||
#[test]
|
||||
fn round_off_next_to_zero_is_no_difference() {
|
||||
let tag = |v: f64| super::exact("0.0000".into(), v);
|
||||
assert_eq!(tag(0.0), tag(1.2e-16));
|
||||
assert_eq!(tag(0.0), tag(-3e-15));
|
||||
assert_ne!(tag(0.0), tag(1e-6), "a real difference below the display precision still shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_differ_below_the_display_precision_still_vary() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let dims: Vec<String> = [10, 20]
|
||||
.map(|x| app.run("dimlinear", json!({ "p1": [0, 0], "p2": [x, 0], "at": [5, 3] })).unwrap()["handle"].as_str().unwrap().to_string())
|
||||
.to_vec();
|
||||
app.run("select", json!({ "handles": dims.clone() })).unwrap();
|
||||
app.run("properties.set", json!({ "handles": [dims[0].clone()], "textHeight": 7.0 })).unwrap();
|
||||
app.run("properties.set", json!({ "handles": [dims[1].clone()], "textHeight": 7.0 + 1e-13 })).unwrap();
|
||||
let texts = painted_texts(&mut app, properties_section);
|
||||
assert!(!texts.iter().any(|t| t == VARIES), "noise below 12 digits is not a difference; painted: {texts:?}");
|
||||
app.run("properties.set", json!({ "handles": [dims[1].clone()], "textHeight": 7.000001 })).unwrap();
|
||||
let texts = painted_texts(&mut app, properties_section);
|
||||
assert!(texts.iter().any(|t| t == VARIES), "7 and 7.000001 differ although both show as 7.0000; painted: {texts:?}");
|
||||
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let circles = circles(&mut app, &[2, 2], json!({}));
|
||||
app.run("select", json!({ "handles": circles.clone() })).unwrap();
|
||||
app.run("properties.set", json!({ "handles": [circles[1].clone()], "radius": 2.000001 })).unwrap();
|
||||
let h = palette(app);
|
||||
assert!(h.query_all_by_value(VARIES).count() > 0, "radii 2 and 2.000001 vary");
|
||||
}
|
||||
|
||||
/// Two objects of each type, selected together: the palette shows the type's properties (not
|
||||
/// just General), `*VARIES*` for the ones that differ, and an edit reaches both objects.
|
||||
#[test]
|
||||
fn several_objects_of_one_type_show_their_properties_with_varies() {
|
||||
let cases: [(&str, [Value; 2], &[&str]); 6] = [
|
||||
("line", [json!({ "points": [[0, 0], [10, 0]] }), json!({ "points": [[0, 5], [20, 5]] })], &["Start X", "Length", "Angle"]),
|
||||
("circle", [json!({ "center": [0, 0], "radius": 2 }), json!({ "center": [9, 9], "radius": 5 })], &["Center X", "Radius", "Area"]),
|
||||
(
|
||||
"text",
|
||||
[json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })],
|
||||
&["Contents", "Height", "Rotation"],
|
||||
),
|
||||
(
|
||||
"pline",
|
||||
[json!({ "vertices": [[0, 0], [4, 0], [4, 4]] }), json!({ "vertices": [[0, 9], [8, 9], [8, 12]] })],
|
||||
&["Global width", "Length", "Closed"],
|
||||
),
|
||||
(
|
||||
"arc",
|
||||
[json!({ "center": [0, 0], "radius": 3, "start": 0, "end": 90 }), json!({ "center": [9, 0], "radius": 6, "start": 10, "end": 80 })],
|
||||
&["Radius", "Start angle", "End angle"],
|
||||
),
|
||||
(
|
||||
"mtext",
|
||||
[json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })],
|
||||
&["Contents", "Text height", "Defined width"],
|
||||
),
|
||||
];
|
||||
for (command, params, labels) in cases {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let handles: Vec<String> = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let out = app.run(command, p.clone()).unwrap();
|
||||
out["handle"].as_str().map_or_else(|| out["handles"][0].as_str().unwrap().to_string(), str::to_string)
|
||||
})
|
||||
.collect();
|
||||
app.run("select", json!({ "handles": handles })).unwrap();
|
||||
let texts = painted_texts(&mut app, properties_section);
|
||||
for want in labels {
|
||||
assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "{command}: the palette shows `{want}`; painted: {texts:?}");
|
||||
}
|
||||
assert!(texts.iter().any(|t| t == VARIES), "{command}: differing values show {VARIES}; painted: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of every selected circle.
|
||||
fn selected_radii(app: &CadApp) -> Vec<f64> {
|
||||
let d = app.session.doc().unwrap();
|
||||
app.session
|
||||
.selection()
|
||||
.iter()
|
||||
.filter_map(|h| d.entity(*h))
|
||||
.filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The radius of each circle in `handles`, selected or not.
|
||||
fn radii_of(app: &CadApp, handles: &[String]) -> Vec<f64> {
|
||||
let d = app.session.doc().unwrap();
|
||||
handles
|
||||
.iter()
|
||||
.filter_map(|h| d.entity(cadcraft_doc::Handle::parse_hex(h)?))
|
||||
.filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A palette showing `app`'s selection, a few frames in.
|
||||
fn palette(app: CadApp) -> egui_kittest::Harness<'static, CadApp> {
|
||||
let mut h =
|
||||
egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Types `text` into the first `*VARIES*` field (the Radius, whose row comes first) and commits it.
|
||||
fn type_into_first_varies(h: &mut egui_kittest::Harness<'static, CadApp>, text: &str) {
|
||||
let field = h.get_all_by_value(VARIES).next().unwrap();
|
||||
field.focus();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(2);
|
||||
h.get_all_by_value(VARIES).next().unwrap().type_text(text);
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn circles(app: &mut CadApp, radii: &[i32], extra: Value) -> Vec<String> {
|
||||
radii
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut p = json!({ "center": [0, 0], "radius": r });
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
app.run("circle", p).unwrap()["handle"].as_str().unwrap().to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A value typed into the palette goes to every selected object, not just the first.
|
||||
#[test]
|
||||
fn typing_a_shared_value_changes_every_selected_object() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let handles = circles(&mut app, &[2, 5, 9], json!({}));
|
||||
app.run("select", json!({ "handles": handles })).unwrap();
|
||||
let mut h = palette(app);
|
||||
type_into_first_varies(&mut h, "7");
|
||||
assert_eq!(selected_radii(h.state()), vec![7.0, 7.0, 7.0], "all three circles take the typed radius");
|
||||
}
|
||||
|
||||
/// The edit to several objects is one undo step.
|
||||
#[test]
|
||||
fn editing_several_objects_is_one_undo_step() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let handles = circles(&mut app, &[2, 5, 9], json!({}));
|
||||
app.run("select", json!({ "handles": handles.clone() })).unwrap();
|
||||
let mut h = palette(app);
|
||||
type_into_first_varies(&mut h, "7");
|
||||
assert_eq!(radii_of(h.state(), &handles), vec![7.0, 7.0, 7.0], "the edit reached all three");
|
||||
h.state_mut().run("undo", json!({})).unwrap();
|
||||
assert_eq!(radii_of(h.state(), &handles), vec![2.0, 5.0, 9.0], "one undo puts back all three radii");
|
||||
}
|
||||
|
||||
/// A mixed selection has no type to show: only the properties every object has (General), so
|
||||
/// no Radius row, however different the circles' radii are. One object is unchanged: its own
|
||||
/// values, nothing varies.
|
||||
#[test]
|
||||
fn mixed_types_show_general_only_and_one_object_is_unchanged() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let mut handles = circles(&mut app, &[2, 5], json!({}));
|
||||
let line = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
let circles_only = handles.clone();
|
||||
handles.push(line);
|
||||
app.run("select", json!({ "handles": handles })).unwrap();
|
||||
let mut h = palette(app);
|
||||
assert_eq!(h.query_all_by_value(VARIES).count(), 0, "two circles and a line show no Radius to vary");
|
||||
h.state_mut().run("select", json!({ "handles": circles_only.clone() })).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_value(VARIES).count() > 0, "the two circles alone do show it");
|
||||
h.state_mut().run("select", json!({ "handles": [circles_only[0].clone()] })).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.query_all_by_value(VARIES).count(), 0, "one circle shows its own radius");
|
||||
assert!(h.query_all_by_value("2.0000").count() > 0);
|
||||
}
|
||||
|
||||
/// The values the palette works out are kept for a drawing revision and a selection, and
|
||||
/// worked out again when either changes.
|
||||
#[test]
|
||||
fn shared_values_follow_the_drawing_and_the_selection() {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
let handles: Vec<String> =
|
||||
[2, 5].map(|r| app.run("circle", json!({ "center": [0, 0], "radius": r })).unwrap()["handle"].as_str().unwrap().to_string()).to_vec();
|
||||
app.run("select", json!({ "handles": handles.clone() })).unwrap();
|
||||
let mut h =
|
||||
egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_value(VARIES).count() > 0, "two different radii show {VARIES}");
|
||||
h.state_mut().run("properties.set", json!({ "handles": handles, "radius": 4.0 })).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_value(VARIES).count() == 0, "after both radii are 4 nothing varies");
|
||||
h.state_mut().run("select", json!({ "handles": [handles[0].clone()] })).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_value("4.0000").count() > 0, "one circle alone shows its radius");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
|
||||
let name = r.name.clone();
|
||||
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
|
||||
let op = |option: &str| json!({ "option": option, "names": name, "exact": true });
|
||||
let op = |option: &str| json!({ "option": option, "names": name });
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(8.0);
|
||||
if ui.button(crate::tl!("Reload")).clicked() {
|
||||
@@ -216,7 +216,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
|
||||
.clicked()
|
||||
{
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
|
||||
}
|
||||
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
|
||||
*act = Some(Act::Run("xopen", json!({ "name": name })));
|
||||
@@ -230,7 +230,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
|
||||
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
|
||||
if (a || o) && overlay != r.overlay {
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -243,7 +243,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
|
||||
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|
||||
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
|
||||
if apply {
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() })));
|
||||
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
|
||||
ui.data_mut(|d| d.remove::<String>(path_id));
|
||||
} else if text != r.path {
|
||||
ui.data_mut(|d| d.insert_temp(path_id, text));
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! ALIASEDIT aliases are user settings (#1899): they are saved in the user profile the desktop
|
||||
//! and web hosts keep across restarts (`profile_json` → `load_profile`), never in the drawing.
|
||||
//! The alias store is process-wide, so these tests run one at a time and use their own names.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::MAX_ALIASES;
|
||||
use cadcraft_engine::cmd::resolve_alias;
|
||||
use cadcraft_ui_egui::options::MAX_PROFILE_BYTES;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn app() -> CadApp {
|
||||
CadApp::new(Session::new(), Services::default())
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn profile(value: &str) -> Value {
|
||||
serde_json::from_str(value).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_survive_a_restart_and_command_names_still_win() {
|
||||
let _one = lock();
|
||||
let mut a = app();
|
||||
a.run("aliasedit", json!({ "set": { "zzpersist": "qsave" } })).unwrap();
|
||||
let saved = a.profile_json();
|
||||
assert!(profile(&saved)["aliases"]["aliases"].get("zzpersist").is_some(), "the profile holds the alias: {saved}");
|
||||
|
||||
// A fresh app restores the profile: the alias works, built-ins and command names unchanged.
|
||||
let mut b = app();
|
||||
b.load_profile(&saved);
|
||||
assert_eq!(resolve_alias("zzpersist"), "qsave");
|
||||
assert_eq!(resolve_alias("ZZPERSIST"), "qsave");
|
||||
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
|
||||
assert_eq!(resolve_alias("line"), "line", "command names still win");
|
||||
assert_eq!(resolve_alias("zzunknown"), "zzunknown");
|
||||
|
||||
// A hand-edited profile can't make an alias take over a command name.
|
||||
let mut edited = profile(&saved);
|
||||
edited["aliases"]["aliases"]["line"] = json!("circle");
|
||||
let mut c = app();
|
||||
c.load_profile(&edited.to_string());
|
||||
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
|
||||
a.run("aliasedit", json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_and_cleared_aliases_stay_removed() {
|
||||
let _one = lock();
|
||||
let mut a = app();
|
||||
a.run("aliasedit", json!({ "set": { "zzgone": "line", "zzkeep": "circle" } })).unwrap();
|
||||
assert_eq!(resolve_alias("zzgone"), "line");
|
||||
|
||||
a.run("aliasedit", json!({ "remove": ["zzgone"] })).unwrap();
|
||||
let saved = a.profile_json();
|
||||
let mut b = app();
|
||||
b.load_profile(&saved);
|
||||
assert_eq!(resolve_alias("zzgone"), "zzgone", "a removed alias stays removed");
|
||||
assert_eq!(resolve_alias("zzkeep"), "circle", "the other aliases still restore");
|
||||
|
||||
a.run("aliasedit", json!({ "clear": true })).unwrap();
|
||||
let cleared = a.profile_json();
|
||||
let mut c = app();
|
||||
c.load_profile(&cleared);
|
||||
assert_eq!(resolve_alias("zzkeep"), "zzkeep", "a cleared alias stays cleared");
|
||||
assert_eq!(profile(&cleared)["aliases"]["aliases"], json!({}), "the profile holds no user aliases");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_saved_aliases_are_skipped_safely() {
|
||||
let _one = lock();
|
||||
let mut a = app();
|
||||
a.run("aliasedit", json!({ "set": { "zzsafe": "line" } })).unwrap();
|
||||
let saved = profile(&a.profile_json());
|
||||
assert_eq!(resolve_alias("zzsafe"), "line");
|
||||
|
||||
let mut huge = serde_json::Map::new();
|
||||
for i in 0..MAX_ALIASES.saturating_add(2_000) {
|
||||
huge.insert(format!("zz{i}"), json!("line"));
|
||||
}
|
||||
let mut huge_saved = saved.clone();
|
||||
huge_saved["aliases"] = json!({ "aliases": huge, "file": "mine.pgp" });
|
||||
let mut bad_values = saved.clone();
|
||||
bad_values["aliases"] = json!({ "aliases": { "zzbad": 5, "zznames": { "x": 1 }, "zzreal": "line" } });
|
||||
let mut not_an_object = saved.clone();
|
||||
not_an_object["aliases"] = json!("zzsafe, *LINE");
|
||||
let oversized = format!(r#"{{"aliases": {}, "pad": "{}"}}"#, saved["aliases"], "x".repeat(MAX_PROFILE_BYTES));
|
||||
|
||||
let mut b = app();
|
||||
b.load_profile(&huge_saved.to_string()); // must not panic
|
||||
let listing = b.run("aliasedit", json!({})).unwrap();
|
||||
let count = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
|
||||
assert!(count <= MAX_ALIASES, "restored {count} aliases");
|
||||
for bad in [bad_values.to_string(), not_an_object.to_string(), oversized] {
|
||||
b.load_profile(&bad); // must not panic
|
||||
}
|
||||
// Entries that aren't aliases of real commands are skipped.
|
||||
assert_eq!(resolve_alias("zzbad"), "zzbad", "a non-string command is skipped");
|
||||
assert_eq!(resolve_alias("zzreal"), "line", "valid entries beside bad ones still restore");
|
||||
a.run("aliasedit", json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_alias_set_saved_by_the_profile_restores_after_a_restart() {
|
||||
let _one = lock();
|
||||
// ALIASEDIT holds up to MAX_ALIASES aliases. Long names make the saved profile far larger
|
||||
// than a small one, and the loader must still accept everything `profile_json` writes.
|
||||
let mut a = app();
|
||||
let mut set = serde_json::Map::new();
|
||||
let mut names = Vec::new();
|
||||
for i in 0..MAX_ALIASES {
|
||||
let name = format!("zz{i:05}alias{}", "x".repeat(40));
|
||||
names.push(name.clone());
|
||||
set.insert(name, json!("line"));
|
||||
}
|
||||
a.run("aliasedit", json!({ "set": set })).unwrap();
|
||||
let saved = a.profile_json();
|
||||
assert!(saved.len() > 64 * 1024, "the saved profile crosses 64 KiB: {} bytes", saved.len());
|
||||
assert!(saved.len() <= MAX_PROFILE_BYTES, "the limit covers a full alias set: {} bytes", saved.len());
|
||||
|
||||
let mut b = app();
|
||||
b.load_profile(&saved);
|
||||
let listing = b.run("aliasedit", json!({})).unwrap();
|
||||
let count = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
|
||||
assert_eq!(count, MAX_ALIASES, "every saved alias restores");
|
||||
for name in [names.first(), names.last()].into_iter().flatten() {
|
||||
assert_eq!(resolve_alias(name), "line", "restored {name}");
|
||||
}
|
||||
a.run("aliasedit", json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_one_byte_over_the_limit_is_refused_safely() {
|
||||
let _one = lock();
|
||||
let defaults = app().run("options", json!({})).unwrap();
|
||||
// Still valid JSON that would change PICKBOX, so accepting it would show.
|
||||
let overhead = r#"{"sysvars":{"PICKBOX":9},"pad":""}"#.len();
|
||||
let over = format!(r#"{{"sysvars":{{"PICKBOX":9}},"pad":"{}"}}"#, "x".repeat(MAX_PROFILE_BYTES + 1 - overhead));
|
||||
assert_eq!(over.len(), MAX_PROFILE_BYTES + 1);
|
||||
let mut b = app();
|
||||
b.load_profile(&over); // must not panic
|
||||
assert_eq!(b.run("options", json!({})).unwrap(), defaults, "an oversized profile changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_alias_file_survives_with_its_path_and_reinit_rereads_it() {
|
||||
let _one = lock();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-alias-profile-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("mine.pgp").to_string_lossy().into_owned();
|
||||
std::fs::write(&file, "ZZPROFILE, *RECTANG\n").unwrap();
|
||||
|
||||
let mut a = app();
|
||||
a.run("aliasedit", json!({ "file": file.clone() })).unwrap();
|
||||
assert_eq!(resolve_alias("zzprofile"), "rectang");
|
||||
let saved = a.profile_json();
|
||||
assert_eq!(profile(&saved)["aliases"]["file"], json!(file), "the profile remembers the alias file");
|
||||
|
||||
// A restart keeps both the aliases and the file REINIT reads.
|
||||
let mut b = app();
|
||||
b.load_profile(&saved);
|
||||
assert_eq!(resolve_alias("zzprofile"), "rectang");
|
||||
let listing = b.run("aliasedit", json!({})).unwrap();
|
||||
assert_eq!(listing["file"], json!(file));
|
||||
std::fs::write(&file, "ZZPROFILE, *CIRCLE\n").unwrap();
|
||||
b.run("reinit", json!({})).unwrap();
|
||||
assert_eq!(resolve_alias("zzprofile"), "circle", "REINIT re-reads the restored file");
|
||||
a.run("aliasedit", json!({ "clear": true })).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ mod named_viewports_dialog;
|
||||
mod native_menu_text_keys;
|
||||
mod native_windows;
|
||||
mod options_dialog;
|
||||
mod options_right_click;
|
||||
mod parameters_rename;
|
||||
mod pickdrag;
|
||||
mod properties_units;
|
||||
@@ -30,6 +31,7 @@ mod rapid_clicks;
|
||||
mod selection_cycling;
|
||||
mod shared_controls;
|
||||
mod shortcut_menus;
|
||||
mod shortcuts_checked;
|
||||
mod status_bar_toggles;
|
||||
mod style_dialogs;
|
||||
mod system_cursor;
|
||||
|
||||
@@ -138,7 +138,7 @@ fn hostile_saved_profiles_are_ignored() {
|
||||
"[]".into(),
|
||||
r#"{"sysvars": "PICKBOX"}"#.into(),
|
||||
r#"{"sysvars": {"PICKBOX": "x", "LUNITS": 4, "FONTALT": "evil.ttf"}, "modelBackground": "red", "snapMarkerSize": -1}"#.into(),
|
||||
format!(r#"{{"sysvars": {{"PICKBOX": 5}}, "pad": "{}"}}"#, "x".repeat(70_000)),
|
||||
format!(r#"{{"sysvars": {{"PICKBOX": 5}}, "pad": "{}"}}"#, "x".repeat(options::MAX_PROFILE_BYTES)),
|
||||
] {
|
||||
let mut b = app();
|
||||
b.load_profile(&bad);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Options ▸ User Preferences ▸ Right-Click Customization: the time-sensitive right-click's
|
||||
//! duration, and what a right-click does with nothing selected (Default mode), with a selection
|
||||
//! (Edit mode) and while a command runs (Command mode), through the OPTIONS JSON form, the
|
||||
//! drawing area and the dialog.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::options::{CommandClick, IdleClick};
|
||||
use cadcraft_ui_egui::{CadApp, Services, menus};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> CadApp {
|
||||
CadApp::new(Session::new(), Services::default())
|
||||
}
|
||||
|
||||
/// One frame at `time` (seconds) with `events`; returns the painted text.
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>, time: f64) -> String {
|
||||
fn collect(shape: &egui::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::Shape::Text(s) => {
|
||||
text.push_str(s.galley.text());
|
||||
text.push('\n');
|
||||
}
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
app.logic(ctx);
|
||||
let mut out = ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
let mut text = String::new();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut text));
|
||||
text
|
||||
}
|
||||
|
||||
const AT: egui::Pos2 = egui::pos2(800.0, 500.0);
|
||||
|
||||
fn button(pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos: AT, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// A right-click on the drawing area from `t` held for `held` seconds; returns the text painted
|
||||
/// just after.
|
||||
fn right_click(app: &mut CadApp, ctx: &egui::Context, t: f64, held: f64) -> String {
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(AT)], t);
|
||||
frame(app, ctx, vec![button(true)], t + 0.01);
|
||||
frame(app, ctx, vec![button(false)], t + 0.01 + held);
|
||||
frame(app, ctx, Vec::new(), t + 0.05 + held)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_settings_are_checked_and_survive_a_restart() {
|
||||
let mut a = app();
|
||||
let all = a.run("options", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(all["rightClickDuration"].clone(), all["rightClickDefault"].clone(), all["rightClickEdit"].clone(), all["rightClickCommand"].clone()),
|
||||
(json!(250), json!("menu"), json!("menu"), json!("menu"))
|
||||
);
|
||||
let r = a
|
||||
.run(
|
||||
"options",
|
||||
json!({"rightClickDuration": 400, "rightClickDefault": "repeat", "rightClickEdit": "Repeat", "rightClickCommand": "menuWithOptions"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["rightClickDuration"].clone(), r["rightClickEdit"].clone()), (json!(400), json!("repeat")));
|
||||
assert_eq!(a.options.right_click_command, CommandClick::MenuWithOptions);
|
||||
|
||||
let before = a.run("options", json!({})).unwrap();
|
||||
for bad in [
|
||||
json!({"rightClickDuration": 50}),
|
||||
json!({"rightClickDuration": 2001}),
|
||||
json!({"rightClickDuration": f64::NAN}),
|
||||
json!({"rightClickDuration": "long"}),
|
||||
json!({"rightClickDefault": "enter"}),
|
||||
json!({"rightClickEdit": 1}),
|
||||
json!({"rightClickCommand": "repeat", "PICKBOX": 9}),
|
||||
] {
|
||||
assert!(a.run("options", bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing");
|
||||
}
|
||||
|
||||
let mut b = app();
|
||||
b.load_profile(&a.profile_json());
|
||||
assert_eq!(b.options, a.options);
|
||||
let mut c = app();
|
||||
c.load_profile(r#"{"rightClickDuration": 99999, "rightClickDefault": "fly", "rightClickCommand": null}"#);
|
||||
assert_eq!(c.options, app().options, "hostile saved values are skipped");
|
||||
let r = a.run("options", json!({"reset": true})).unwrap();
|
||||
assert_eq!((r["rightClickDuration"].clone(), r["rightClickDefault"].clone()), (json!(250), json!("menu")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_right_click_can_repeat_the_last_command() {
|
||||
let mut a = app();
|
||||
a.run("options", json!({"rightClickDefault": "repeat"})).unwrap();
|
||||
a.cmdline("circle 0,0 5");
|
||||
assert!(!a.session.prompting());
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut a, &ctx, Vec::new(), 0.0);
|
||||
right_click(&mut a, &ctx, 0.1, 0.05);
|
||||
assert!(a.session.prompting(), "CIRCLE runs again: {}", a.session.prompt_text());
|
||||
a.cmdline("10,10 2");
|
||||
|
||||
// With a selection, Edit mode decides: the menu (its default) opens instead.
|
||||
a.run("selectall", json!({})).unwrap();
|
||||
assert!(!a.session.selection().is_empty());
|
||||
let text = right_click(&mut a, &ctx, 1.0, 0.05);
|
||||
assert!(!a.session.prompting(), "no command started: {}", a.session.prompt_text());
|
||||
assert!(text.contains("Erase"), "the shortcut menu opens:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_mode_and_click_duration_decide_enter_or_the_menu() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// A longer duration: a 0.5 s click is still Enter.
|
||||
let mut a = app();
|
||||
a.run("options", json!({"rightClickDuration": 1000})).unwrap();
|
||||
frame(&mut a, &ctx, Vec::new(), 0.0);
|
||||
a.cmdline("LINE 0,0 10,0");
|
||||
right_click(&mut a, &ctx, 0.1, 0.5);
|
||||
assert!(a.session.running.is_none(), "a click shorter than the duration is Enter: {}", a.session.prompt_text());
|
||||
|
||||
// Time-sensitive off, Command mode Enter: every right-click is Enter.
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("options", json!({"timeSensitiveRightClick": false, "rightClickCommand": "enter"})).unwrap();
|
||||
frame(&mut a, &ctx, Vec::new(), 0.0);
|
||||
a.cmdline("LINE 0,0 10,0");
|
||||
let text = right_click(&mut a, &ctx, 0.1, 0.6);
|
||||
assert!(a.session.running.is_none(), "Enter ends LINE: {}", a.session.prompt_text());
|
||||
assert!(!text.contains("Snap Overrides"), "no menu:\n{text}");
|
||||
|
||||
// Command mode "enabled when command options are present": CIRCLE's first prompt has
|
||||
// options, so the menu opens and the command keeps running.
|
||||
a.run("options", json!({"rightClickCommand": "menuWithOptions"})).unwrap();
|
||||
a.cmdline("CIRCLE");
|
||||
let text = right_click(&mut a, &ctx, 2.0, 0.05);
|
||||
assert!(text.contains("Cancel"), "the command menu opens:\n{text}");
|
||||
assert!(a.session.prompting(), "CIRCLE still runs");
|
||||
}
|
||||
|
||||
/// With `CADCRAFT_OPTIONS_SHOTS` set to a folder, the dialog is rendered there for a look.
|
||||
const SHOTS: &str = "CADCRAFT_OPTIONS_SHOTS";
|
||||
|
||||
fn harness(app: CadApp) -> Harness<'static, CadApp> {
|
||||
let builder = Harness::builder().with_size(egui::vec2(1400.0, 900.0));
|
||||
let builder = if std::env::var_os(SHOTS).is_some() { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn shot(h: &mut Harness<'static, CadApp>, name: &str) {
|
||||
if let Some(dir) = std::env::var_os(SHOTS).map(std::path::PathBuf::from) {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.render().unwrap().save(dir.join(name)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_customization_window_sets_every_mode() {
|
||||
let mut a = app();
|
||||
menus::activate(&mut a, "options");
|
||||
a.ui.dialog = Some("options:preferences".into());
|
||||
let mut h = harness(a);
|
||||
h.get_by_label("Right-Click Customization...").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Longer click duration:");
|
||||
shot(&mut h, "options-right-click.png");
|
||||
|
||||
// Default mode's "Repeat last command" comes first, Edit mode's second.
|
||||
if let Some(n) = h.get_all_by_label("Repeat last command").next() {
|
||||
n.click();
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().options.right_click_default, IdleClick::Repeat);
|
||||
assert_eq!(h.state().options.right_click_edit, IdleClick::Menu);
|
||||
|
||||
// Command mode applies once the time-sensitive right-click is off.
|
||||
h.get_by_label("Turn on time-sensitive right-click").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().options.time_sensitive_right_click);
|
||||
h.get_by_label("Shortcut Menu: enabled when command options are present").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().options.right_click_command, CommandClick::MenuWithOptions);
|
||||
|
||||
let unnamed = h.unnamed_widgets();
|
||||
assert!(unnamed.is_empty(), "unnamed controls: {unnamed:?}");
|
||||
// The window's Close comes after the Options dialog's in the tree.
|
||||
if let Some(n) = h.get_all_by_label("Close").last() {
|
||||
n.click();
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Longer click duration:").is_none());
|
||||
assert_eq!(h.state().ui.dialog.as_deref(), Some("options:preferences"));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! User shortcuts are checked however they arrive: `ui.set` drops what the preferences would,
|
||||
//! and a malformed preference value leaves the shortcuts as they are.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle};
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn call(app: &mut CadApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
|
||||
let (req, _reply) = ControlRequest::new(method, params);
|
||||
match handle(app, ctx, &req) {
|
||||
Outcome::Done(v) => v,
|
||||
_ => panic!("unexpected screenshot"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_keeps_only_shortcuts_the_preferences_would() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let v = call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.set",
|
||||
json!({"shortcuts": {"line": "Ctrl+Shift+L", "circle": "not a key", "nosuchcommand": "Ctrl+Shift+K", "move": "Ctrl+Shift+L"}}),
|
||||
);
|
||||
assert_eq!(v["ok"], true, "{v}");
|
||||
let ids: Vec<&String> = app.ui.shortcuts.keys().collect();
|
||||
assert_eq!(ids, vec!["line"], "a bad key, an unknown command and a key taken twice are dropped: {:?}", app.ui.shortcuts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_preference_keeps_the_shortcuts() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.load_prefs(r#"{"shortcuts": {"line": "Ctrl+Shift+L"}}"#);
|
||||
assert_eq!(app.ui.shortcuts.len(), 1);
|
||||
app.load_prefs(r#"{"shortcuts": "Ctrl+Shift+L"}"#);
|
||||
assert_eq!(app.ui.shortcuts.get("line").map(String::as_str), Some("Ctrl+Shift+L"), "not cleared");
|
||||
}
|
||||
@@ -105,3 +105,27 @@ fn double_click_elsewhere_starts_no_command() {
|
||||
assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), None, "{at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1081: double-clicking a multileader's text edits it, as for TEXT and MTEXT.
|
||||
#[test]
|
||||
fn double_click_on_a_multileaders_text_starts_textedit() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("mleader", json!({ "points": [[16, -10], [20, -5]], "text": "example text" })).unwrap();
|
||||
h.run_steps(3);
|
||||
let at = h
|
||||
.state()
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
|
||||
.unwrap();
|
||||
double_click(&mut h, at + Vec2::new(1.0, -0.05));
|
||||
let s = &h.state().session;
|
||||
assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("textedit"));
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert!(s.editing_mtext().is_some(), "the in-place editor opens on it");
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
|
||||
- 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.
|
||||
username. Release builds regenerate it for the release commit
|
||||
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
|
||||
- [`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
|
||||
|
||||
@@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit
|
||||
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
|
||||
| `ui.resize` | `{width, height}` | Resize the window. |
|
||||
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. In the web build there is no file system: `path` is ignored and the PNG comes back inline (`pngBase64`, `width`, `height`, `bytes`). |
|
||||
| `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (the canvas; the default without a plot style), `"white"` (the default with one, as a plot prints on paper), `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds; `"none"` (or `""`) draws every object with its own properties, also in a layout whose page setup shows plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. |
|
||||
| `app.open` / `app.save` / `app.quit` | `{path}` | File operations. |
|
||||
| `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. |
|
||||
|
||||
+13
-4
@@ -8,7 +8,7 @@ and what is not there yet.
|
||||
| LISP routines (`.lsp`) | Run with APPLOAD or `(load "name")`. The core language, `command`, entity lists, selection sets, `get…` input and `c:` commands work. | [autolisp.md](autolisp.md) |
|
||||
| Startup LISP (`acad.lsp`, `acaddoc.lsp`) | Loaded from the support folder at startup and for each drawing. | [autolisp.md](autolisp.md#at-startup) |
|
||||
| Scripts (`.scr`) | SCRIPT (Tools > Run Script...), `cadcraft-cli run --script-file`, and script text over MCP and the control channel. | below |
|
||||
| Command aliases (`.pgp`) | ALIASEDIT `{file}` loads an alias file for the session; REINIT reads it again. | below |
|
||||
| Command aliases (`.pgp`) | ALIASEDIT `{file}` loads an alias file and keeps it; the aliases and the file survive restarts. REINIT reads it again. | below |
|
||||
| Compiled plug-ins (C++, .NET) | Not supported. A plug-in API is planned, for plug-ins written in Rust and compiled to WebAssembly. | [gaps.md](gaps.md) (E4) |
|
||||
| Programs that drive the CAD program from outside | The JSON control channel, the MCP server and `cadcraft-cli`. Any language that can open a TCP socket or start a process can use them. | below |
|
||||
|
||||
@@ -34,9 +34,18 @@ cadcraft-cli run drawing.dxf --script-file setup.scr --save out.dxf
|
||||
|
||||
## Command aliases
|
||||
|
||||
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines) and adds its aliases for the rest of
|
||||
the session. Lines that would start external programs are skipped. From a script or the control
|
||||
channel: `aliasedit {"file": "/path/to/aliases.pgp"}`.
|
||||
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines); its aliases replace the ones you
|
||||
had. Lines that would start external programs are skipped. From a script or the control channel:
|
||||
`aliasedit {"file": "/path/to/aliases.pgp"}`. The Command Aliases dialog's **Import…** button picks
|
||||
a `.pgp` file, loads it the same way, shows its path and warnings, and remembers the folder it came
|
||||
from; on the web (no native file dialog) type `ALIASEDIT` with the file's contents in its `text`
|
||||
parameter instead.
|
||||
|
||||
Aliases are user settings, never part of a drawing. The desktop and web apps keep them, and the
|
||||
file REINIT reads, with your other preferences, so aliases added, edited, imported or removed in
|
||||
the Command Aliases dialog (or with ALIASEDIT's `set`, `remove` and `clear`) are there after a
|
||||
restart, and REINIT reads the same file again. The headless CLI keeps no preferences: there,
|
||||
aliases last for that run.
|
||||
|
||||
## Driving CADCraft from another program
|
||||
|
||||
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (R2000 DXF CLASS records without instance counts, so AutoCAD LT 2026 opens them) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (re-audit against main 7cee28580: justification, ATTDEF and CLASS rows; file-test count; previously: R2000 DXF CLASS records without instance counts, so AutoCAD LT 2026 opens them; previously: attribute value edits refresh exported baselines; previously: LAYOUT SAveas writes a layout as a DWT template; previously: re-audit against main 41d9b5e95: acadrust 0.6.5, ACIS data, dynamic block, SCRIPT and SHX rows, io test count; previously: DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
|
||||
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
|
||||
@@ -31,10 +31,10 @@ below, and ≈ 20–40 h for 3D exchange formats that wait for 3D modelling.
|
||||
|
||||
AutoCAD 2027 opens every DWG version from R2.0 onward and saves 2018 (AC1032, still the current
|
||||
DWG format), 2013, 2010, 2007, 2004, 2000 and R14. CADCraft reads DWG by converting it to DXF
|
||||
with the `acadrust` 0.6.3 crate and reading that DXF; it saves by writing its own R2000 DXF and
|
||||
with the `acadrust` 0.6.6 crate (failsafe mode since 0.6.4: an object record it can't decode is left out and a note says so, #859; if the file's header or section map can't be read, or it isn't a DWG, the file is rejected) and reading that DXF; it saves by writing its own R2000 DXF and
|
||||
converting it to a DWG of the chosen version (#421): SAVEAS `version`, else the drawing's own
|
||||
version (the file's `$ACADVER`, 2018 for a new drawing), at least 2004 unless 2000 is asked for.
|
||||
The bridge repairs two acadrust 0.6.3 gaps in 2010+ DWG: merged table cells and table-style
|
||||
The bridge repairs two acadrust gaps in 2010+ DWG (measured with 0.6.3): merged table cells and table-style
|
||||
title/header suppression.
|
||||
|
||||
| DWG version | Code | AutoCAD 2027 | CADCraft read | CADCraft write | Tested how |
|
||||
@@ -51,7 +51,7 @@ title/header suppression.
|
||||
| Web build | — | — | **no** (error: "save as DXF") | no | — |
|
||||
|
||||
Everything lost in the DXF read/write paths below is also lost through DWG, plus whatever
|
||||
`acadrust`'s own DXF output omits (proxy objects, ACIS data, custom objects: not measured). User
|
||||
`acadrust`'s own DXF output omits (proxy objects and custom objects: not measured; ACIS data of solids is kept, #781). User
|
||||
report #57: a 10 MB DWG failed after about two minutes; limits now stop runaway conversions
|
||||
early (#175), but the throughput problem remains. DGN line style components that no line style
|
||||
definition uses (leftovers of DGN imports, hundreds of thousands in some drawings) are left out
|
||||
@@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
|---|---|---|---|
|
||||
| LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests |
|
||||
| POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement; a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); Verify, Preset and Lock position modes kept (#876); unchanged values retain imported-font placement; a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written |
|
||||
@@ -84,13 +84,13 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
| HATCH pattern / solid | yes | yes; associative hatches keep their boundary objects in blocks too, and the boundary carries the hatch reactor (stretched dynamic blocks drag the fill in AutoCAD) | |
|
||||
| HATCH gradient | yes | yes (#162, fixes #80) | |
|
||||
| SOLID, TRACE, 3DFACE | yes | yes | |
|
||||
| ACAD_TABLE | yes | yes (entity + block) | |
|
||||
| ACAD_TABLE | yes; cell text from AutoCAD files is readable (cell height, style scale, MTEXT; #806) | yes (entity + block) | |
|
||||
| VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read; view twist (51) | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) |
|
||||
| IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | the picture is loaded from its file and drawn (#1756); brightness and contrast are kept but not applied (FF10) |
|
||||
| WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). |
|
||||
| MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | |
|
||||
| REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | |
|
||||
| 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); **dropped by DWG save** (acadrust) | only their layer follows edits |
|
||||
| REGION | other programs' REGIONs (ACIS data) kept as Unknown; the ACIS data of 2013+ files (`ACDSDATA`) is kept with them (#781) | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | |
|
||||
| 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); 3DSOLID, REGION and BODY keep their ACIS data (`ACDSDATA`) in files of 2013 and later, in DXF and DWG (#781); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); the other kinds are **dropped by DWG save** (acadrust) | only their layer follows edits |
|
||||
| Header variables | all read | 36 written | AutoCAD writes about 280 |
|
||||
| Tables: LAYER (+ transparency, description), LTYPE, STYLE, DIMSTYLE, BLOCK_RECORD | yes | yes | |
|
||||
| Tables: VPORT, VIEW, UCS, APPID | yes | yes: saved viewport configurations (#504) with each tile's view twist (51), named views, UCSs | UCS elevation lost |
|
||||
@@ -98,7 +98,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
| Objects: GROUP, MLINESTYLE, MLEADERSTYLE, IMAGEDEF, SCALE | yes | yes (groups #417, scale list #468) | |
|
||||
| Objects: VISUALSTYLE, PLOTSETTINGS, materials, field objects and other unmodelled objects | kept raw (#528) | written back as read; a proxy object (ACAD_PROXY_OBJECT) keeps its class: its group 91 is renumbered to the written CLASSES, which keep the class it names, and one whose number names no class is dropped (#1812) | not editable |
|
||||
| Foreign xdata, extension dictionaries, reactors, unmodelled objects, colour books | kept raw (#528); an entity's xdata is part of the entity, so COPY, MIRROR, ARRAY and paste keep it on the copies (EXPLODE pieces don't) | written back, handles remapped, references to deleted objects dropped | through DWG: xdata on table records and blocks, entity colour books and reactors, custom-class objects lost (acadrust) |
|
||||
| Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; references: their dynamic block, values and custom properties (`blocks.reference`, Properties ▸ Custom, read-only); kept raw (#528) | written back as read; a changed visibility state is written as AutoCAD writes it (a new anonymous block with its `AcDbBlockRepBTag`, the objects' `AcDbBlockRepETag`, the reference's representation data) | visibility states (and lookups that set only them) can be changed (`blocks.reference.set`, Properties ▸ Custom); distances, positions and the actions they drive not yet; DWG open and save keep it too (checked with an AutoCAD dynamic block) |
|
||||
| Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; references: their dynamic block, values and custom properties (`blocks.reference`, Properties ▸ Custom); kept raw (#528) | written back as read; a changed reference is written as AutoCAD writes it (a new anonymous block with its `AcDbBlockRepBTag`, the objects' `AcDbBlockRepETag`, the reference's representation data) | visibility states, lookups (both ways), distances and positions can be changed (`blocks.reference.set`, Properties ▸ Custom, #860, #878), running the stretch and move actions that follow, and their grips show and drag (#883); polar, XY, rotation, alignment and flip parameters and the scale, rotate, flip, array and polar stretch actions are read but not applied (`dynblock/apply.rs`); no authoring; DWG open and save keep it too (checked with an AutoCAD dynamic block; open PR #1000 keeps the C++ class names the file gives) |
|
||||
| Handles and owners | entity handles kept; others regenerated | consistent | stable across CADCraft re-saves (#495); other programs' table and object handles renumbered once |
|
||||
|
||||
**DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects
|
||||
@@ -117,10 +117,10 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
| DWF/DWFx export, DWF underlay | yes | no | 10–15 |
|
||||
| DGN import/export/underlay | yes | no (needs a public spec; owner decision) | 25–40 |
|
||||
| WMF, EPS, ACIS SAT, STL, FBX, IGES/STEP (Windows), 3DS | various | no; most are 3D and wait for M11 | 20–40 |
|
||||
| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD | 8–12 |
|
||||
| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD (wide East Asian characters fill square big-font cells, #861) | 8–12 |
|
||||
| Linetype (`.lin`) and hatch pattern (`.pat`) files | load user files | user files load (#552); linetype shape (SHX) elements are skipped | 1–2 |
|
||||
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | plot style tables in our own JSON format ([plot-styles.md](plot-styles.md)) and kept in the drawing; plotter devices with papers and printable areas in our own JSON format ([plotters.md](plotters.md), #564); binary `.ctb`/`.stb`/`.pc3`/`.pmp` not read (no public spec) | 4–8 |
|
||||
| Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 |
|
||||
| Scripts (`.scr`) | SCRIPT | the SCRIPT command runs a `.scr` file (#693), as do the CLI, MCP and control channel | 0–1 |
|
||||
| Aliases (`.pgp`), CUI(x), workspaces | yes | user alias files and ALIASEDIT (#552); no CUI(x) or workspaces | 4–8 |
|
||||
| SVG import (`.svg`, `.svgz`) | yes (via scripts) | OPEN reads shapes, paths, groups and Inkscape layers into entities (#636); no CSS classes, `use`, fills, text or images (what the drawing loses is named in a note on open, #627) | 2–4 |
|
||||
| AutoLISP (`.lsp`, `.fas`, `.vlx`), `.dll`/`.arx` plug-ins | yes | `.lsp` loads (APPLOAD, `(load)`, a dropped file; #758); no Visual LISP ActiveX (`vlax-`), DCL dialogs or reactors; `.fas`/`.vlx` are compiled and undocumented; no `.dll`/`.arx` | see [gaps.md](gaps.md) (E1) |
|
||||
@@ -129,7 +129,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
|
||||
## Tests
|
||||
|
||||
`crates/io/src/tests.rs` has 36 tests (round trips of entities, styles, associativity, tables,
|
||||
`crates/io/src/tests.rs` has 86 tests (`#[test]` count, 2026-10-24) (round trips of entities, styles, associativity, tables,
|
||||
constraints, non-ASCII text, DWG round trips, hostile input). **All inputs are synthetic**:
|
||||
inline strings or our own writer's output. No test opens a file made by another program, and no
|
||||
external validator runs in CI (ezdxf's audit was run by hand once, 2026-10-06). The next step is
|
||||
@@ -144,6 +144,7 @@ our output in AutoCAD black-box.
|
||||
| 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) |
|
||||
| 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. |
|
||||
| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: acadrust 0.6.5, ACIS data, dynamic block, SCRIPT and SHX rows, io test count |
|
||||
| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
|
||||
| 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
|
||||
| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
|
||||
|
||||
+24
-23
@@ -1,6 +1,6 @@
|
||||
# Where CADCraft falls short of AutoCAD
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 updated) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (re-audit against main 7cee28580: file-test and catalog counts, REFEDIT, dynamic-block and QSAVE rows; previously: QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 updated; previously: BEDIT in the Block Editor switches blocks: F3 updated; previously: Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected; previously: DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated; re-audit against main 41d9b5e95: CI gate (#6, S1), MVIEW Restore (F15, U11), open-PR and catalog counts, file-test count; previously: Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||
list: agents pick from here (top of a section first). A PR names the rows it closes in its
|
||||
@@ -13,12 +13,12 @@ do them first.
|
||||
|
||||
| # | Gap | Why it ranks here | Est. | Doc |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) |
|
||||
| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) |
|
||||
| 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
|
||||
| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place** (xrefs, #525, XCLIP, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) |
|
||||
| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; the ACIS data of 3D solids, regions and bodies is kept, #781; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) |
|
||||
| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 88 file tests (`crates/io/src/tests.rs`) use synthetic data (the real-file runs in PRs #781 and #859 were manual and are not committed); nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) |
|
||||
| 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset recomputes arc bulges since #825 but has no loop cleanup) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
|
||||
| 4 | **[alpha blocker]** **No dynamic-block authoring; xrefs can't be edited in place** (REFEDIT, REFSET and REFCLOSE edit block references in place, #818; XCLIP and XCLIPFRAME clip block and xref references, #2354; dynamic block references are read and edited, #838–#883; xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) |
|
||||
| 5 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) |
|
||||
| 6 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) |
|
||||
| 6 | ~~**No CI runs the Rust tests on pull requests**~~ fixed: `.github/workflows/ci.yml` runs `cargo xtask ci` on every pull request and push to main through the shared gate workflow (#1987); `cargo fmt` on main is fixed. | — | 0 | [stability](#stability) |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -31,15 +31,15 @@ do them first.
|
||||
| F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
|
||||
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
|
||||
| F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
|
||||
| F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 |
|
||||
| F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 6 settings (arrow size, text height, landing gap, dogleg, text style, annotative; block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `cmd/mleader_edit.rs`, `doc/src/tables.rs` | Medium | 8–12 |
|
||||
| F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing TEXT is edited as plain text on the command line with TEXTEDIT (alias DDEDIT; a double-click starts it, #868), while an existing MTEXT opens in the in-place editor with its contents (#1002, merged 2026-11-06); stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 |
|
||||
| F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 |
|
||||
| F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 |
|
||||
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
|
||||
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
|
||||
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 |
|
||||
| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 |
|
||||
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): the Verify, Preset and Lock position modes are kept in files and Lock position is applied by INSERT and ATTSYNC (#876), but INSERT doesn't act on Verify or Preset, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
|
||||
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export Open and Save has the backup copy checkbox (ISAVEBAK). | `ui-egui/src/options/` | Low | 2–4 |
|
||||
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
|
||||
@@ -94,11 +94,11 @@ Detail: [hardware-parity.md](hardware-parity.md).
|
||||
|
||||
| # | Gap | Est. |
|
||||
|---|---|---|
|
||||
| H1 | 3Dconnexion SpaceMouse (needs the device and 3D views) | 6–10 + human |
|
||||
| H2 | Multi-monitor: floating palettes on other screens, per-monitor DPI (PR #110 for Windows 11 DPI) | 4–6 |
|
||||
| H3 | Plotters and system printers (PC3 devices) beyond PDF files | 6–10 |
|
||||
| H1 | 3Dconnexion SpaceMouse: 2D pan and zoom through raw HID on Windows and Linux landed (#650, Tools ▸ 3D Mouse; PR #983 open); none on macOS or the web, no 3D navigation (needs 3D views), not yet checked on a physical device in #650 | 6–10 + human |
|
||||
| H2 | Multi-monitor: floating palettes on other screens, per-monitor DPI (the Windows 11 DPI fix is in open PR #668, which replaced the closed PR #110) | 4–6 |
|
||||
| H3 | System printers (PC3 devices) beyond PDF, PNG and SVG plotter devices (CUPS printing on macOS and Linux is open PR #755) | 6–10 |
|
||||
| H4 | Pen tablets / digitizer (TABLET) | 3–5 + human |
|
||||
| H5 | Screen magnifier tracking (#39, PR #187) | 1–2 |
|
||||
| H5 | Screen magnifier tracking: the system-cursor option landed (PR #187); issue #39 is still open | 1–2 |
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -106,28 +106,28 @@ 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 | 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 |
|
||||
| L1 | Catalog covers the UI (Ukrainian reference catalog: 1,368 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 |
|
||||
| L2 | Japanese from AutoCAD for Mac's list, plus hi and id from the standard's list (18 non-English catalogs have landed in `crates/ui-egui/src/i18n/`, counted 2026-11-07: ar, bs, cs, de, el, es, fr, hu, it, ka, ko, pt-BR, ru, sv, tr, uk, vi, zh-Hans; Vietnamese and Georgian are shipped but their English-fallback counts are not measured here); the landed catalogs still need native review and a fresh `cargo xtask i18n` fallback count | 10–16 + native review |
|
||||
| L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI (the Arabic catalog shipped without mirroring; reading its labels right to left is open PR #996). Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 |
|
||||
| L4 | IME fixes on every platform (PRs #110–#119 were closed unmerged; the consolidated winit PR #668 is open); vertical text styles | 6–10 |
|
||||
|
||||
## Stability
|
||||
|
||||
| # | Gap | Evidence | Est. |
|
||||
|---|---|---|---|
|
||||
| S1 | No CI runs `cargo xtask ci` on pull requests (only packaging lint and release builds) | `.github/workflows/` | 2–3 |
|
||||
| S1 | ~~No CI runs `cargo xtask ci` on pull requests~~ fixed: `ci.yml` runs it on every pull request and push to main (#1987) | `.github/workflows/ci.yml` | 0 |
|
||||
| S2 | ~~`cargo fmt --check` fails on main~~ fixed on main (8fceaaf) | — | 0 |
|
||||
| S3 | 40 open PRs, many community bug fixes awaiting review | `gh pr list` | 10–20 |
|
||||
| S3 | 450 open PRs (2026-10-24), many community bug fixes awaiting review | `gh pr list` | 10–20 |
|
||||
| S4 | Open correctness issues: extents union far-apart geometry (#166), lines not visible mid-command (#44), browser plot shows raw PDF bytes (#134) (closing a tab and browser saves fixed, #76, #133) | issues | 4–6 |
|
||||
| S5 | No smoke test of every command through the UI (PR #42 proposes one) | — | 6–10 |
|
||||
| S5 | No smoke test of every command through the UI (open PR #42 proposes one) | — | 6–10 |
|
||||
|
||||
## Platforms
|
||||
|
||||
| # | Gap | Est. |
|
||||
|---|---|---|
|
||||
| P1 | Web build: plot shows raw PDF bytes (#134), no DWG (File > Open and save downloads fixed, #55, #133) | 3–5 |
|
||||
| P2 | Windows and Linux get little runtime testing; Linux Mint mouse delay (#25); Windows magnifier (#39) | 6–10 |
|
||||
| P3 | macOS file associations: opening `.dxf` from Finder fails (#158) | 1–2 |
|
||||
| P2 | Windows and Linux get little runtime testing; Linux Mint mouse delay (#25: the vsync fix landed, #183, the issue is open); Windows magnifier (#39: system-cursor option landed, #187, the issue is open) | 6–10 |
|
||||
| P3 | macOS file associations: opening `.dxf` from Finder fails (#158; open PRs #683 and #899) | 1–2 |
|
||||
|
||||
## Ecosystem and automation
|
||||
|
||||
@@ -156,6 +156,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles |
|
||||
| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated |
|
||||
| 2026-10-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: CI gate (rows 6 and S1, #1987), MVIEW Restore (F15, U11, #633), PDF margins (F14), open-PR, catalog and file-test counts re-checked against main 41d9b5e95 |
|
||||
| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed |
|
||||
| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |
|
||||
| 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed |
|
||||
|
||||
+10
-9
@@ -1,6 +1,6 @@
|
||||
# Geometry parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ELLIPSE axis endpoints, other-axis point and Included angle as AutoCAD stores them) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ELLIPSE axis endpoints, other-axis point and Included angle as AutoCAD stores them; previously: re-audit against main 41d9b5e95: polyline offset bulges, fit-spline cap, test counts; previously: bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right
|
||||
to the last displayed digit or the tool is unusable for production drawings. This document
|
||||
@@ -10,8 +10,8 @@ in [ui-parity.md](ui-parity.md); dimension and hatch behaviour in [gaps.md](gaps
|
||||
|
||||
**Headline (estimated): geometry ≈ 40% (weighted below: 41%), of which the constraint solver ≈ 50%.** Everything built
|
||||
from lines, arcs and circles is exact. Ellipses and splines are handled by tessellating them, so
|
||||
intersections, offsets, trims and snaps on them are approximations; polyline offsets don't
|
||||
recompute arc bulges or clean up loops; tolerances are absolute, so very large or very small
|
||||
intersections, offsets, trims and snaps on them are approximations; polyline offsets recompute
|
||||
arc bulges (#825) but don't clean up loops; tolerances are absolute, so very large or very small
|
||||
drawings behave differently from mid-sized ones. 3D geometry doesn't exist. Remaining:
|
||||
**≈ 90–140 Opus 5.5 hours** for 2D precision and the solver, plus the 3D kernel (M11, see
|
||||
[target-app-parity.md](target-app-parity.md)).
|
||||
@@ -50,7 +50,7 @@ constraints 10%.
|
||||
| Circle, arc, bulge polyline | yes | yes, exact |
|
||||
| Ellipse, elliptical arc | yes | yes (center, major axis, ratio, parameters) |
|
||||
| Spline: CV form, rational, any degree | yes | yes: rational B-spline, basis-function evaluation |
|
||||
| Spline: fit points | degree 3 with end tangents, knot parameterisation (chord, square root, uniform), fit tolerance | cubic, chord-length, averaged knots, dense solve; **no end tangents, no knot parameterisation choice, no fit tolerance** (`geom/src/spline.rs:31-88`) |
|
||||
| Spline: fit points | degree 3 with end tangents, knot parameterisation (chord, square root, uniform), fit tolerance | cubic, chord-length, averaged knots, dense solve (above 4,096 fit points the points are used as control points instead, #773); **no end tangents, no knot parameterisation choice, no fit tolerance** (`geom/src/spline.rs:31-88`) |
|
||||
| Spline: closed/periodic | periodic | "closed" appends the first point and refits; **not periodic** (`modify2.rs:647`) |
|
||||
| Spline editing: knot insertion, split, refine, rebuild, convert to polyline | SPLINEDIT, grips | knot insertion and splitting (`curves.rs:539-622`); refine/rebuild partial |
|
||||
| Helix | yes | **no** |
|
||||
@@ -76,7 +76,7 @@ constraints 10%.
|
||||
| Circle, arc | exact | exact (radius change) |
|
||||
| Ellipse | spline approximation within tolerance | **a polyline** from tessellation at 1e-3 × major axis |
|
||||
| Spline | spline approximation within tolerance | tessellate at 1e-3, offset vertices along normals, keep about 40 points, refit: **lossy** |
|
||||
| Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; **arc bulges not recomputed** after endpoints move (comment at L413), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed |
|
||||
| Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; arc bulges are recomputed from the joined endpoints (#825), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed |
|
||||
| Options: Through, Erase, Layer, Multiple | yes | see [gaps.md](gaps.md) |
|
||||
|
||||
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 70%)
|
||||
@@ -104,7 +104,7 @@ Where line and arc are supported the results are exact.
|
||||
| Tangent points, common tangents | every curve | circles and arcs only (`curve.rs:170-183`) |
|
||||
| AREA: circle, ellipse | exact | exact area (ellipse perimeter by Ramanujan, not exact); an open elliptical arc has no area, like an arc |
|
||||
| AREA: polyline with arcs | exact (bulge segments) | **tessellated at 1e-4** (`curve.rs:491`) |
|
||||
| MASSPROP (regions: area, perimeter, centroid, moments) | exact on regions and solids | shoelace over a tessellated circle, polyline or first hatch loop (`utility.rs:310-352`); no regions |
|
||||
| MASSPROP (regions: area, perimeter, centroid, moments) | exact on regions and solids | works on regions (#510, `inquiry/regions.rs`); other objects not re-checked in this pass (shoelace over a tessellated circle, polyline or first hatch loop on 2026-10-10) |
|
||||
| LIST/DIST/ID precision | full double | full double |
|
||||
|
||||
## Hatch boundaries (≈ 45%)
|
||||
@@ -112,7 +112,7 @@ Where line and arc are supported the results are exact.
|
||||
`geom/src/region.rs`: curves are polygonised, crossings split on a grid, vertices merged at
|
||||
`tol`, a ray picks the edge and the face is traced by sharpest left turn; islands by
|
||||
connectivity (including islands made of separate lines, #163). Caps: 60,000 segments, 4,096
|
||||
islands. Missing: **gap tolerance (HPGAPTOL)**, true arcs and curves in boundaries (boundaries
|
||||
islands. Gap tolerance (HPGAPTOL) bridges gaps between open curve ends only (`build_gap`). Missing: true arcs and curves in boundaries (boundaries
|
||||
come out tessellated, so a hatched circle's boundary is a polygon), island detection modes
|
||||
beyond the default, boundary sets, and retained associative boundaries for every case. Pattern
|
||||
lines are scanline families clipped against the tessellated loops (`render/src/hatch.rs`).
|
||||
@@ -131,7 +131,7 @@ lines are scanline families clipped against the tessellated loops (`render/src/h
|
||||
|
||||
## Tests
|
||||
|
||||
`#[test]` counts on 2026-10-10: geom 28, constraints 22, render 50, engine 174. A property-based
|
||||
`#[test]` counts (occurrences under `crates/<name>`) on 2026-10-24: geom 101, constraints 42, render 157, engine 1153 (2026-10-10, counted differently: geom 28, constraints 22, render 50, engine 174). A property-based
|
||||
and hostile-input suite for geometry numerics landed 2026-10-12 (`crates/geom/src/property_tests.rs`):
|
||||
a deterministic xorshift generator (no new dependency) drives a few thousand cases per property —
|
||||
intersections lie on both curves and are symmetric, circle pairs match the closed form, closest
|
||||
@@ -145,7 +145,7 @@ coordinates to 1e-9 relative.
|
||||
|
||||
1. Exact ellipse and spline intersection and closest point (subdivision + Newton with a
|
||||
relative tolerance); use them in snaps, TRIM, EXTEND, BREAK. 15–25 h.
|
||||
2. Polyline offset with bulge recomputation, self-intersection cleanup and OFFSETGAPTYPE;
|
||||
2. Polyline offset with self-intersection cleanup and OFFSETGAPTYPE (bulge recomputation done, #825);
|
||||
spline/ellipse offset as fitted splines within a tolerance. 15–25 h.
|
||||
3. A tolerance context relative to the drawing's extents, and a known-answer test suite at
|
||||
origin and at 1e6–1e7 coordinates. 8–12 h.
|
||||
@@ -162,6 +162,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | ELLIPSE from axis endpoints matches AutoCAD's stored geometry: the major axis points at the first endpoint (arcs drew on the opposite side of the center), a point at the other-axis prompt gives its distance from the center, and the end-angle prompt offers Included angle |
|
||||
| 2026-10-26 | minor | PEDIT Multiple at the command line (#2182): select objects, lines and arcs turned into polylines on Yes, then Close/Open/Join/Width/Fit/Spline/Decurve/Ltype gen/Reverse/Undo apply to all; Join joins the selected objects with each other within a fuzz distance |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: polyline offset bulges (#825), fit-spline cap (#773), HPGAPTOL, MASSPROP and test counts checked against main 41d9b5e95 |
|
||||
| 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) |
|
||||
| 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) |
|
||||
| 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (zoom and pan regenerate in the background) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: SpaceMouse 2D navigation, magnifier option, winit PR state, plotter devices; the 45% is not re-measured; previously: re-audit against main e707ab4d: SpaceMouse 2D navigation, magnifier option, winit PR state, plotter devices; the 45% is not re-measured) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows)
|
||||
|
||||
The hardware AutoCAD uses and how CADCraft compares, per platform. Work items:
|
||||
[gaps.md](gaps.md#hardware).
|
||||
|
||||
**Headline (estimated): hardware ≈ 45%.** 2D GPU drawing, HiDPI and trackpads are covered on
|
||||
every platform. Missing: 3D mice, tablets/digitizers, GPU-accelerated 3D (there is no 3D), and
|
||||
every platform. Missing: 3D navigation with a 3D mouse (2D pan and zoom landed on Windows and Linux, #650), tablets/digitizers, GPU-accelerated 3D (there is no 3D), and
|
||||
any deliberate multi-monitor handling. Remaining: **≈ 15–25 Opus 5.5 hours**, plus a human with
|
||||
the devices to test 3D mice and tablets.
|
||||
|
||||
@@ -20,15 +20,15 @@ requirements and graphics-performance documentation. Nothing was benchmarked in
|
||||
| CPU fallback | software mode | yes (`tiny-skia` rasteriser) | yes | yes | — |
|
||||
| GPU for 3D (shaded visual styles, ray-traced rendering) | yes | no (no 3D) | no | no | no |
|
||||
| Low-latency presentation | yes | vsync or low-latency via `CADCRAFT_VSYNC` | same | no vsync queue so the crosshair keeps up (#183) | browser-controlled |
|
||||
| HiDPI / Retina, display scaling | yes | yes (egui points per pixel) | yes; a Windows 11 DPI fix is queued upstream (PR #110) | yes | yes |
|
||||
| HiDPI / Retina, display scaling | yes | yes (egui points per pixel) | yes; the Windows 11 DPI fix is in open PR #668 (PR #110 was closed in its favour) | yes | yes |
|
||||
| Multi-monitor (palettes on other screens, per-monitor DPI) | yes | not handled deliberately; one window | same | same | — |
|
||||
| Trackpad pan, pinch zoom | yes (Mac) | yes, with trackpad vs wheel detection (#58) | wheel only (precision touchpad untested) | wheel; delay reported on Linux Mint (#25) | browser events |
|
||||
| Magic Mouse | yes | treated as a trackpad | — | — | — |
|
||||
| Wheel mouse, middle-button pan | yes | yes | yes | yes | yes |
|
||||
| 3D mouse (3Dconnexion SpaceMouse) | yes | **no** | **no** | **no** | no |
|
||||
| 3D mouse (3Dconnexion SpaceMouse) | yes | **no** | 2D pan and zoom through raw HID (#650, Tools ▸ 3D Mouse); not checked on a physical device in the PR | same (hidapi, udev) | no |
|
||||
| Pen tablet / digitizer (TABLET command, calibration) | yes (Windows) | **no** | **no** | **no** | no |
|
||||
| Screen magnifier following the cursor | yes | — | breaks (#39; PR #187 adds a system-cursor option) | — | — |
|
||||
| Plotters and printers (PC3 devices, system printers) | yes | **no**: PDF/PNG/SVG files only; Print asks where to save the PDF (#26) | same | same | PDF bytes shown instead of a download (#134) |
|
||||
| Screen magnifier following the cursor | yes | — | breaks (#39 is open; the option to keep the system cursor over the drawing area landed, #187) | — | — |
|
||||
| Plotters and printers (PC3 devices, system printers) | yes | **no** system printers: PDF, PNG and SVG plotter devices only (#564); the Plot dialog writes the file (#160); CUPS printing is open PR #755 | same | same | PDF bytes shown instead of a download (#134) |
|
||||
|
||||
## Performance on hardware (internal numbers only)
|
||||
|
||||
@@ -48,5 +48,7 @@ next step.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: SpaceMouse 2D pan/zoom (#650), magnifier option (#187), winit PRs #110–#119 closed in favour of open PR #668, system printers still open (PR #755); re-checked against main 41d9b5e95, percentage and hours not re-measured |
|
||||
| 2026-10-13 | minor | Re-audit against main e707ab4d: SpaceMouse 2D pan/zoom (#650), magnifier option (#187), winit PRs #110–#119 closed in favour of open PR #668, system printers still open (PR #755); percentage and hours not re-measured |
|
||||
| 2026-10-13 | minor | Zoom and pan regenerate in the background: 2–4.5 s stalls down to 26–64 ms on a hatched DWG |
|
||||
| 2026-10-10 | major | First hardware parity doc: GPU backends per platform, input devices, displays, plotters |
|
||||
|
||||
+26
-21
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Bosnian added; previously minor: the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Bosnian added; previously: catalog row counts and English-fallback counts re-measured for all fifteen catalogs; previously minor: the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
|
||||
How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in
|
||||
[ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization).
|
||||
@@ -23,8 +23,8 @@ shaping and right-to-left text through TrueType, vertical SHX text styles.
|
||||
- **A display-time string catalog** (`crates/ui-egui/src/i18n/`, landed with #36): a TSV per
|
||||
language (context, English source, translation, `@plural` rows), Edit → Interface Language with
|
||||
Auto (system language), live switching, a saved choice, and tests for coverage, placeholders
|
||||
and plurals ([localization.md](localization.md)). The Ukrainian and German catalogs have
|
||||
**989 rows** each,
|
||||
and plurals ([localization.md](localization.md)). The eighteen non-English catalogs have between 1,026
|
||||
(Russian) and 1,471 rows (Swedish; Ukrainian, the reference, has 1,414), counted 2026-11-07 on main 486d558a7 (rows without comment lines, `@plural` rows included),
|
||||
covering menus, command labels, tool groups and tooltips, the Start page, tabs, status
|
||||
controls, object snaps, drafting prompts, property labels and dialogs. Engine diagnostics,
|
||||
CLI/MCP responses and command history stay English, and uncatalogued text falls back to
|
||||
@@ -36,38 +36,40 @@ shaping and right-to-left text through TrueType, vertical SHX text styles.
|
||||
Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining,
|
||||
lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines
|
||||
are still placed one glyph per code point: **no shaping** for Devanagari and other Indic
|
||||
scripts, and **no vertical text**. SHX fonts are not read; our stroke font stands in.
|
||||
scripts, and **no vertical text**. SHX fonts are not read; our stroke font stands in, and wide East Asian characters in it fill the square cells a big font would give them (#861).
|
||||
- **Text input:** egui/winit IME. The command line — which collects single-line TEXT edits and
|
||||
other prompt text — now enables the operating system IME while a prompt asks for text and shows
|
||||
composition (pre-edit) text until it is committed, so Chinese, Japanese and Korean input works
|
||||
there (#1761); egui's own text fields handle IME themselves. Several upstream IME fixes are
|
||||
queued as PRs (#110–#119: macOS Korean, emoji picker, Windows caret placement, X11 and Wayland),
|
||||
so CJK input is not yet reliable on every platform.
|
||||
there (#1761); egui's own text fields handle IME themselves. The upstream IME fixes (macOS Korean,
|
||||
emoji picker, Windows caret placement, X11 and Wayland) were proposed as PRs #110–#119, which were
|
||||
closed in favour of the consolidated, still open PR #668, so CJK input is not yet reliable on
|
||||
every platform.
|
||||
|
||||
## Languages
|
||||
|
||||
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,
|
||||
about 3–5 h per language for an Opus 5.5 agent to translate the ~1,300 catalog rows and fix layout,
|
||||
plus native review by a human; extending the catalog to the remaining English-only strings
|
||||
(engine diagnostics, uncatalogued text) is about 4–6 h, counted once in the first row.
|
||||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Estimate to `full` |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | en | source (100%) | 100% (help is a command reference, not AutoCAD-sized help) | Latin: yes | n/a | full | — |
|
||||
| Simplified Chinese | zh-Hans | 0 (0%) | 0% | CJK glyphs render via fallback; IME fixes pending (#110–#119); no vertical text | no | none | 8–12 h (includes extending the catalog, 4–6 h) |
|
||||
| Spanish | es | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h |
|
||||
| Simplified Chinese | zh-Hans | 1,337 catalog rows; 84 strings fall back to English | partial | CJK glyphs render via the CJK interface fonts loaded in every language (#861); IME fixes pending (#668); no vertical text | contributed by @mayloglog (#684); no native review recorded | partial | 2–4 h + native review |
|
||||
| Spanish | es | 1,243 catalog rows; 115 strings fall back to English | dialogs and tooltips yes | Latin: yes | contributed by @PedroGuille (#610) and extended by @diegozpy (#680); no native review recorded | partial | 1–2 h |
|
||||
| Hindi | hi | 0 (0%) | 0% | Devanagari: no shaping | no | none | 3–5 h + 15–25 h shaping (shared with Arabic) |
|
||||
| Arabic | ar | 0 (0%) | 0% | Drawing text shaped and laid out right to left (#287); the UI is not mirrored | no | none | 3–5 h + 15–25 h RTL UI |
|
||||
| French | fr | ≈ 900 catalog rows (gaps reported, English fallback) | partial | Latin: yes | contributed by @pechnikov2026-star (#624); no native review yet | partial | 2–4 h |
|
||||
| Russian | ru | ≈ 900 catalog rows (gaps reported, English fallback) | partial | Cyrillic: yes (bundled egui fonts) | contributed by @pechnikov2026-star (#624); no native review yet | partial | 2–4 h |
|
||||
| Portuguese (Brazil) | pt-BR | 1,231 catalog rows; reference catalog and marked UI literals covered (no gaps reported) | dialogs and tooltips yes; command reference searchable in Portuguese | Latin: yes (bundled egui fonts; accented letters tested) | catalog by @WladmirJunior, completed with AI assistance by @RicSchonfelder; terminology reviewed by a native speaker (@ffishman) | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work |
|
||||
| Arabic | ar | 1,229 catalog rows; 124 strings fall back to English | partial | Drawing text shaped and laid out right to left (#287); interface fonts for Arabic on Linux, Windows and macOS; the UI is not mirrored (reading labels right to left is open PR #996) | contributed by @hasan-sdaif (#798); no native review recorded | partial | 1–2 h + 15–25 h RTL UI |
|
||||
| French | fr | 1,004 catalog rows; 296 strings fall back to English | partial | Latin: yes | contributed by @pechnikov2026-star (#624); no native review yet | partial | 2–4 h |
|
||||
| Russian | ru | 1,005 catalog rows; 295 strings fall back to English | partial | Cyrillic: yes (bundled egui fonts) | contributed by @pechnikov2026-star (#624); no native review yet | partial | 2–4 h |
|
||||
| Portuguese (Brazil) | pt-BR | 1,243 catalog rows; 112 strings added since fall back to English (covered the reference catalog and every marked UI literal when it landed) | dialogs and tooltips yes; command reference searchable in Portuguese | Latin: yes (bundled egui fonts; accented letters tested) | catalog by @WladmirJunior, completed with AI assistance by @RicSchonfelder; terminology reviewed by a native speaker (@ffishman) | complete when it landed; 112 strings added since fall back to English | line-by-line proofreading + shared English-only UI work |
|
||||
| Indonesian | id | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h |
|
||||
| Japanese | ja | 0 (0%) | 0% | CJK glyphs render; IME pending; no vertical text | no | none | 4–6 h |
|
||||
| German | de | 989 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in German | Latin: yes (bundled egui fonts cover ä ö ü Ä Ö Ü ß; long strings will need layout checks) | no native review | partial | 1–2 h |
|
||||
| Greek | el | 1,330 catalog rows (all collected existing source keys) | Menus, dialogs, tooltips, drawing prompts, command labels and contextual headings; English fallback for uncatalogued UI | Greek script; bundled egui glyph check added | Original AI-assisted translation by @Camthalion77, independent native review pending | complete current catalog; uncatalogued UI remains English | native proofreading and platform layout review |
|
||||
| Korean | ko | 1,235 rows (1,229 ordinary, 4 menu, 2 count); full existing catalog union and marked UI coverage | dialogs, tooltips, Options and translated command-reference labels | UI requires catalog-covering installed or optional craft-fonts Hang/Kore face; English fallback without one; IME fixes still pending | original AI-assisted translation; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native review + platform layout/IME validation |
|
||||
| Vietnamese | vi | 0 (0%) | 0% | Latin with stacked diacritics: renders per code point, untested | no | none | 3–5 h |
|
||||
| German | de | 1,031 catalog rows; 284 strings fall back to English | dialogs and tooltips yes; command reference searchable in German | Latin: yes (bundled egui fonts cover ä ö ü Ä Ö Ü ß; long strings will need layout checks) | no native review | partial | 1–2 h |
|
||||
| Greek | el | 1,330 catalog rows; 34 strings fall back to English | Menus, dialogs, tooltips, drawing prompts, command labels and contextual headings; English fallback for uncatalogued UI | Greek script; bundled egui glyph check added | Original AI-assisted translation by @Camthalion77, independent native review pending | complete when it landed; 34 strings added since fall back to English | native proofreading and platform layout review |
|
||||
| Korean | ko | 1,235 catalog rows; 124 strings fall back to English | dialogs, tooltips, Options and translated command-reference labels | UI requires catalog-covering installed or optional craft-fonts Hang/Kore face; English fallback without one; IME fixes still pending | original AI-assisted translation; independent native proofreading pending | complete when it landed; 124 strings added since fall back to English | native review + platform layout/IME validation |
|
||||
| Vietnamese | vi | 1,460 catalog rows (2026-11-07); English-fallback count not measured | catalog shipped; dialog and tooltip coverage not audited | Latin with stacked diacritics: renders per code point, untested | original translations by @2ez4gcx (AI-assisted); no native review recorded | partial | 1–2 h + native review |
|
||||
| Georgian | ka | 1,458 catalog rows (2026-11-07); written to cover the union of the existing catalogs | menus, dialogs and tooltips | Georgian script: drawn with the craft-fonts `Geor` face, none in a build without craft-fonts | original clean-room translation by the CADCraft contributors; no native review recorded | partial | native review |
|
||||
| Ukrainian | uk | 989 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in Ukrainian | Cyrillic: yes (bundled egui fonts) | translated by a contributor; no second review | partial | 2–4 h |
|
||||
| Czech | cs | 1,202 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings; command reference searchable in Czech | Latin: yes (bundled egui proportional and monospace fonts; Czech letters and quotes tested) | AI-assisted by @Losminek; independent review pass; not yet proofread by a native speaker | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work |
|
||||
| Swedish | sv | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings; command reference searchable in Swedish | Latin: yes (bundled egui proportional and monospace fonts; å ä ö é and Swedish quotes tested) | AI-assisted (Claude Code) by @holmbom; terminology and the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work |
|
||||
@@ -76,21 +78,24 @@ plus native review by a human; extending the catalog to the remaining English-on
|
||||
| Turkish | tr | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings (Ölçülendirme / Ölçü); command reference searchable in Turkish | Latin: yes (bundled egui proportional and monospace fonts; Ç Ğ İ Ö Ş Ü ç ğ ı ö ş ü tested) | AI-assisted by @ogzhnklvz, written from the English strings with Turkish desktop-CAD vocabulary; the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work |
|
||||
| Bosnian | bs | 1,442 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes | Latin: yes (bundled egui fonts) | AI-assisted; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work |
|
||||
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian, Turkish and Bosnian.
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian, Turkish, Bosnian, Georgian and Vietnamese. The per-language cells above keep the counts of the dated audit that wrote them (2026-10-24 or earlier, as stated); only the row totals in "What CADCraft has" and the Vietnamese and Georgian rows are from 2026-11-07, and the English-fallback counts have not been re-measured since 2026-10-24.
|
||||
Czech, Swedish, Hungarian, Brazilian Portuguese and Bosnian (1,442 rows, AI-assisted, not yet native-reviewed) cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text
|
||||
remain English. AutoCAD for Mac ships 7
|
||||
non-English languages (zh-Hans, es, fr, ja, de, ko, it); of those, CADCraft now ships German and Korean,
|
||||
and additionally Ukrainian, which AutoCAD for Mac doesn't ship.
|
||||
|
||||
**Total to put all twelve at `full`:** about 55–95 Opus 5.5 hours (catalog extension 4–6 h,
|
||||
ten translations 30–50 h, complex-script shaping and RTL 30–45 h), plus native-speaker review for
|
||||
**Total to put the languages at `full`:** about 62–102 Opus 5.5 hours, summed from the table's estimates (catalog extension 4–6 h,
|
||||
three languages not started (hi, id, ja) 10–16 h, closing the gaps of the lagging catalogs 15–30 h,
|
||||
complex-script shaping and RTL 30–45 h), plus native-speaker review for
|
||||
each language, which needs humans. Translations parallelise fully once the catalog exists.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | Inventory reconciled with main 486d558a7: 18 non-English catalogs (Georgian and Vietnamese rows added; Vietnamese no longer listed as not started), row totals re-counted; per-language cells and fallback counts keep their earlier dates |
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Latin, Ijekavian) added: 1,442 rows, one/few/other plurals, `bs-*` locales; AI-assisted, native proofreading pending |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: catalog row counts and English-fallback counts re-measured for all fifteen catalogs (main 41d9b5e95) |
|
||||
| 2026-10-22 | minor | Command line IME: enabled while a prompt collects text, composition shown but not saved, commit inserted once; single-line TEXT edits accept CJK input (#1761) |
|
||||
| 2026-10-19 | minor | Brazilian Portuguese: row brought up to date (it still read 0%); native-speaker terminology pass on 69 rows (Bind as Integrar, prompt keywords and SPDX kept in English, Thaw Layer, Snap type, Revolve fixed; snap, viewport, grip, offset, layer on/off and multiline wording made consistent with Brazilian usage) |
|
||||
| 2026-10-15 | minor | Greek (`el`) catalog: 1,330 rows, context/plural/locale/font tests; review pending |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Interface languages
|
||||
|
||||
Choose **Edit → Interface Language** and select **Auto (system language)**, **English**,
|
||||
**Українська**, **Português (Brasil)**, **Deutsch**, **Español**, **Čeština** (Czech), **Русский**, **Français**, **简体中文**, **Italiano**, **한국어** (Korean), **Svenska** (Swedish), **العربية** (Arabic), **Magyar** (Hungarian), **Türkçe** (Turkish), **Ελληνικά** (Greek), **Bosanski** (Bosnian), or **ქართული** (Georgian).
|
||||
**Українська**, **Português (Brasil)**, **Deutsch**, **Español**, **Čeština** (Czech), **Русский**, **Français**, **简体中文**, **Italiano**, **한국어** (Korean), **Svenska** (Swedish), **العربية** (Arabic), **Magyar** (Hungarian), **Türkçe** (Turkish), **Ελληνικά** (Greek), **Bosanski** (Bosnian), **ქართული** (Georgian), or **Tiếng Việt** (Vietnamese).
|
||||
The interface updates without restarting, including the native macOS menus.
|
||||
The choice is saved with the other interface preferences (`cadcraft.prefs`, beside the theme):
|
||||
eframe storage on the desktop, browser local storage on the web. Installations without a saved
|
||||
@@ -22,7 +22,7 @@ Automation can select the language through the existing control channel:
|
||||
```
|
||||
|
||||
The `ui.set` method also accepts `{"interfaceLanguage":"cs"}`. Supported values are `auto`,
|
||||
`en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh`, `it`, `ko`, `sv`, `ar`, `hu`, `tr`, `bs`, `el`, and `ka`; invalid values are rejected without changing the current
|
||||
`en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh`, `it`, `ko`, `sv`, `ar`, `hu`, `tr`, `bs`, `el`, `ka`, and `vi`; invalid values are rejected without changing the current
|
||||
selection. Resetting palettes keeps the language choice.
|
||||
|
||||
## Translation coverage
|
||||
@@ -33,8 +33,8 @@ dialogs, and the About window's controls. The command reference can be searched
|
||||
command ID, English label, or translated label. A missing entry falls back to English and
|
||||
never fails a build; `cargo xtask i18n` (a step of `cargo xtask ci`) lists each language's.
|
||||
|
||||
Czech has 1,202 catalog rows, covering the reference catalog and every marked UI
|
||||
literal, including Options. Four `menu` entries distinguish menu headings from object/command
|
||||
Czech has 1,215 catalog rows (counted 2026-10-24), covering the reference catalog and every marked UI
|
||||
literal when it landed, including Options. Four `menu` entries distinguish menu headings from object/command
|
||||
labels: **Zobrazení / Pohled**, **Kreslení / Kreslit**, **Kótování / Kóta**, and **Modifikace / Upravit**.
|
||||
The translation is AI-assisted work by @Losminek, reviewed against Czech desktop and CAD UI
|
||||
conventions and an independent review pass; it has not yet been proofread by a native speaker.
|
||||
|
||||
+2
-3
@@ -109,9 +109,8 @@ In the file name, characters other than letters, digits, spaces and `-_()+,` bec
|
||||
an active offset is rejected rather than ignored. Layout plots do not use model offsets.
|
||||
- `exportpdf` takes the same options and always writes PDF.
|
||||
|
||||
Model-space PDF plots don't apply the paper's margins yet. They fit the drawing inside the
|
||||
default margins. PNG plots apply them. SVG plots show a layout's whole sheet, or the
|
||||
model-space extents.
|
||||
Model-space PDF plots apply the device paper's margins as the printable area (#633; `io/src/pdf.rs`),
|
||||
as PNG plots do. SVG plots show a layout's whole sheet, or the model-space extents.
|
||||
|
||||
## Plot transparency
|
||||
|
||||
|
||||
+23
-21
@@ -1,6 +1,6 @@
|
||||
# CADCraft roadmap detail
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks: "Use blocks" evidence updated) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: alpha-gate evidence, CI gate, beta checklist states, open-PR and catalog counts; percentages and hours not re-scored; previously: re-audit against main e707ab4d: alpha-gate evidence, beta checklist states and milestone notes; percentages and hours not re-scored) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Forward-looking plan: milestones with status, the current focus, and what comes next with
|
||||
estimates. The summary is [ROADMAP.md](../ROADMAP.md); the work items behind every line are in
|
||||
@@ -9,20 +9,20 @@ Opus 5.5 agent-hours.
|
||||
|
||||
## Current focus
|
||||
|
||||
1. **Gate and land.** (`cargo fmt` on main is fixed.) Add a PR workflow that runs
|
||||
`cargo xtask ci`, then review and land the 47 open PRs, most of them community bug fixes
|
||||
(S1–S3). ≈ 15–25 h.
|
||||
1. **Gate and land.** (`cargo fmt` on main is fixed, and `ci.yml` runs `cargo xtask ci` on every
|
||||
pull request since #1987.) Review and land the 450 open PRs (2026-10-24), most of them community bug fixes
|
||||
(S3). ≈ 15–25 h.
|
||||
2. **Stop losing data on save** (FF1, FF2, FF7) and save DWG/DXF at a chosen version (FF3).
|
||||
≈ 30–45 h.
|
||||
3. **A real-file corpus** and a check of our output in AutoCAD (FF5, needs the owner).
|
||||
4. **Precision input:** object snap tracking, extension/parallel snaps, FROM/M2P/TK, typed snap
|
||||
overrides, editable dynamic input (U1–U4). ≈ 25–35 h.
|
||||
5. **Unstub the prompt options** of the most-used commands (F1). ≈ 15–25 h.
|
||||
6. **Autosave, recovery, AUDIT, RECOVER** (F4). ≈ 10–15 h.
|
||||
7. **The rest of the alpha gate:** block editor and REFEDIT, xref attach (F2, F3), and plot styles
|
||||
with a plot dialog and preview (F14). ≈ 50–80 h.
|
||||
4. ~~**Precision input:** object snap tracking, extension/parallel snaps, FROM/M2P/TK, typed snap
|
||||
overrides, editable dynamic input (U1–U4).~~ Done (#379, #393, #441, #60, #519); U4 and the grip and selection leftovers (U5, U6) remain.
|
||||
5. ~~**Unstub the prompt options** of the most-used commands (F1).~~ Done (#397, #408, #547, #558, #559); the stubs left are listed in F1.
|
||||
6. ~~**Autosave, recovery, AUDIT, RECOVER** (F4).~~ Done on the desktop (#406, #416); F4 lists the web-build leftovers.
|
||||
7. **The rest of the alpha gate:** ~~block editor and REFEDIT, xref attach (F2, F3), and plot styles
|
||||
with a plot dialog and preview (F14)~~ landed (#824, #818, #525, #410, #160, #186); left: the precise-drafting gaps (approximate ellipse and spline intersections, prompt options that still print "not available yet"; U4, F1), dynamic-block authoring, editing an xref in place (F2, F3), and printing beyond files (F14, H3). ≈ 50–80 h.
|
||||
|
||||
Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows.
|
||||
Items 2 and 7 are the open [alpha gate](#alpha-gate) rows.
|
||||
|
||||
## Alpha gate
|
||||
|
||||
@@ -31,11 +31,11 @@ the alpha checklist. Main platform: macOS.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho and coordinate entry work; object snap tracking, extension and parallel snaps are toggles with no behaviour; no FROM/M2P/TK or typed snap overrides; TRIM/EXTEND standard mode, OFFSET Erase/Layer/Multiple, ROTATE Reference print "not available yet"; ellipse/spline intersections approximate ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U1, U3, U4, F1) | 35–50 |
|
||||
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
|
||||
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes, ATTSYNC, BATTMAN editing, xrefs (#525) and the Block Editor (BEDIT/BSAVE/BCLOSE: every reference updates) work; no REFEDIT, so a block can't be edited in place in the drawing; no XCLIP; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
|
||||
| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho, coordinate entry, object snap tracking, extension, parallel and apparent snaps (#379), FROM/M2P/TK and typed snap overrides, TRIM/EXTEND, OFFSET and ROTATE options (#397) work; ellipse/spline intersections approximate; a few prompt options still print "not available yet" ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U4, F1) | 35–50 |
|
||||
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER (with MLEADEREDIT/ALIGN/COLLECT, #816), tables and the in-place MTEXT editor (#792) work; no DIMSTYLE dialog, MLEADER styles thin, no fields (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
|
||||
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes, ATTSYNC, BATTMAN editing, xrefs (#525), the Block Editor (BEDIT/BSAVE/BCLOSE: every reference updates), REFEDIT (#818: a block reference is edited in place), XCLIP and XCLIPFRAME (#2354) and dynamic block references (visibility, distances, stretch and move actions, lookups, grips; #838–#883) work; an xref can't be edited in place, no dynamic-block authoring (F2, F3) | 35–55 |
|
||||
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups, PDF output, plot styles (CTB/STB, #410), the Plot dialog (#160), Plot Preview and PUBLISH (#186) and plotter devices (#564) work; printers only via PDF/PNG/SVG files (F14, H3) | 15–25 |
|
||||
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | DXF keeps what we don't model (#528) and writes native MULTILEADERs (#459, not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects and some foreign data; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
|
||||
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | DXF keeps what we don't model (#528) and writes native MULTILEADERs (#459, not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects and some foreign data (ACIS data of solids is kept, #781; damaged DWGs open partially, #859); never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
|
||||
| **To alpha** | | | **≈ 125–190** |
|
||||
|
||||
Ready for real work is ≈ 38%, also under the ~40% alpha bar; closing the gate rows lifts it past
|
||||
@@ -48,16 +48,16 @@ remaining gaps are known bugs and edge features (≈ 75% ready for real work).
|
||||
|
||||
| Item | State | Est. |
|
||||
|---|---|---|
|
||||
| DWG/DXF: nothing lost on save; MULTILEADER native; version choice; code pages; real-file corpus green; verified in AutoCAD | not started | 50–75 |
|
||||
| DWG/DXF: nothing lost on save; MULTILEADER native; version choice; code pages; real-file corpus green; verified in AutoCAD | partial (DXF lossless #528, MULTILEADER #459, version choice #421, code pages #505 landed; corpus and AutoCAD check not started) | 50–75 |
|
||||
| 2D geometry exact for ellipses and splines; polyline offset correct; relative tolerances | not started | 50–80 |
|
||||
| Precision input at AutoCAD level (tracking, dynamic input, overrides, point modifiers, grips, selection keywords, context menus) | partial | 90–140 |
|
||||
| Every Draw/Modify prompt option works; associative arrays | partial | 40–60 |
|
||||
| Annotation depth: DIMSTYLE manager and variables, missing DIM commands, in-place MTEXT, fields, MLEADER editing, annotative scaling, tables | partial | 90–130 |
|
||||
| Xrefs, block editor, dynamic blocks, groups, attribute tools | not started | 90–135 |
|
||||
| Xrefs, block editor, dynamic blocks, groups, attribute tools | partial (xrefs, Block Editor, REFEDIT, groups, attribute tools and dynamic block references landed, XCLIP included; authoring and in-place xref editing not started) | 90–135 |
|
||||
| Plot styles, plot preview, PUBLISH, non-rectangular viewports | partial | 30–45 |
|
||||
| Autosave, recovery, AUDIT/RECOVER, Options dialog, templates, SHX, user `.lin`/`.pat` | partial | 40–60 |
|
||||
| AutoLISP core subset and SCRIPT | done (AutoLISP #758, SCRIPT #693); Visual LISP, DCL and reactors to come | 30–50 |
|
||||
| Catalog plus AutoCAD for Mac's languages | not started | 30–45 |
|
||||
| Catalog plus AutoCAD for Mac's languages | partial (eighteen catalogs shipped, six of AutoCAD for Mac's seven other languages; Japanese and the catalog extension not started) | 30–45 |
|
||||
| Stability: CI gate, issue backlog, command smoke sweep, geometry fuzzing | partial | 30–50 |
|
||||
| Platforms: web open/save, Windows/Linux runtime checks; performance at 1M entities and large DWG | partial | 20–35 |
|
||||
| **Total to beta** | | **≈ 600–930** |
|
||||
@@ -73,7 +73,7 @@ agents that built each milestone, are in the progress log of [ROADMAP.md](../ROA
|
||||
| # | Milestone | Status | Remaining (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton and vertical slice: workspace, AutoCAD-style UI, command line, draw/modify basics, DXF, CLI, MCP, web | done | — |
|
||||
| M1 | Drafting core: every 2D Draw command and option, dynamic input, object snap tracking, snap overrides | ≈ 55% (Draw breadth strong; tracking, dynamic input, overrides missing) | 35–55 |
|
||||
| M1 | Drafting core: every 2D Draw command and option, dynamic input, object snap tracking, snap overrides | ≈ 55% (percentage from 2026-10-10 and not re-scored: tracking, dynamic input and overrides have landed since) | 35–55 |
|
||||
| M2 | Modify: grips, PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curves, MATCHPROP UI | ≈ 50% | 45–70 |
|
||||
| M3 | Layers and properties | ≈ 70% (open layer bugs #71–#75, #87) | 10–15 |
|
||||
| M4 | Annotation: DIM*, DIMSTYLE manager, MLEADER, in-place MTEXT, fields, tables, annotative scaling, SHX | ≈ 40% | 90–130 |
|
||||
@@ -84,8 +84,8 @@ agents that built each milestone, are in the progress log of [ROADMAP.md](../ROA
|
||||
| M9 | Parametric | ≈ 50% | 20–30 |
|
||||
| M10 | Performance: 1M entities at 60 fps, large DWG loads | ≈ 60% (internal numbers only) | 20–40 |
|
||||
| M11 | 3D: UCS, orbit, visual styles, solids, surfaces, meshes, sections, rendering | ≈ 2% | 300–450 |
|
||||
| M12 | Automation: AutoLISP, SCRIPT, action recorder, sheet sets, Express Tools | ≈ 15% (MCP, CLI, control channel done) | 130–220 |
|
||||
| M13 | 1.0 polish: preferences, workspaces, localisation, accessibility, signed packages, docs | ≈ 15% (signed packages for every platform exist; themes done; string catalog with Ukrainian and German, live language switching, #36) | 110–170 |
|
||||
| M12 | Automation: AutoLISP, SCRIPT, action recorder, sheet sets, Express Tools | ≈ 15% (percentage from 2026-10-10 and not re-scored; MCP, CLI, control channel, AutoLISP core #855 and SCRIPT #693 done) | 130–220 |
|
||||
| M13 | 1.0 polish: preferences, workspaces, localisation, accessibility, signed packages, docs | ≈ 15% (signed packages for every platform exist; themes done; string catalog with eighteen languages, live language switching, #36; percentage from 2026-10-10 and not re-scored) | 110–170 |
|
||||
| — | Geometry precision (cross-cutting, see [geometry-parity.md](geometry-parity.md)) | ≈ 40% | 70–110 |
|
||||
| — | AI features | ≈ 0% in-app | 40–80 |
|
||||
| | **Remaining to full parity** | | **≈ 1,300–2,100** |
|
||||
@@ -102,6 +102,8 @@ AutoLISP surface, AI features with openly licensed local models, and every remai
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: alpha-gate evidence, CI gate (#1987), beta-checklist states, open-PR and catalog counts re-checked against main 41d9b5e95; percentages and hours not re-scored |
|
||||
| 2026-10-13 | minor | Re-audit against main e707ab4d: alpha-gate evidence for drafting, annotation, blocks and DWG rows; beta-checklist states for DWG/DXF, blocks and languages; current-focus items 4–7; milestone notes M1, M12, M13; percentages and hours not re-scored |
|
||||
| 2026-10-12 | minor | Block Editor for static blocks landed: "Use blocks" evidence updated; status and hours unchanged (REFEDIT and dynamic blocks remain) |
|
||||
| 2026-10-11 | minor | Alpha-gate evidence refreshed for merged work (#459, #525, #528, #160, #186, #564); statuses and hours unchanged |
|
||||
| 2026-10-10 | minor | Merged main: fmt fixed, Ukrainian catalog noted in M13, ready for real work 38% |
|
||||
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: CADCraft vs AutoCAD 2027
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (XCLIP; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-27 · **Change:** minor (XCLIP; re-audit of the evidence text against main 41d9b5e95; percentages, hours and breadth counts not re-measured) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work
|
||||
remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall,
|
||||
@@ -12,7 +12,7 @@ and the deep checklists are [geometry-parity.md](geometry-parity.md),
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (AutoCAD for Mac menu items with a live command) | **245 / 491 = 50%** (233 = 47% counted by `cargo xtask parity`, which sees engine commands only; +12 UI-only items) | measured ([parity-checklist.md](parity-checklist.md), source scan) |
|
||||
| **Feature breadth** (AutoCAD for Mac menu items with a live command) | **245 / 491 = 50%** at the 2026-10-10 re-measure, not re-measured since (the generated checklist now reads 298 / 491, engine commands only; 233 = 47% counted by `cargo xtask parity`, which sees engine commands only; +12 UI-only items) | measured ([parity-checklist.md](parity-checklist.md), source scan) |
|
||||
| Feature breadth, 2D menus only (excluding 150 3D items) | **≈ 65%** (222 / 341 by `cargo xtask parity`'s rule) | measured (source scan, 2026-10-10) |
|
||||
| Feature depth, weighted by use (features dimension) | **≈ 41%** | estimated (table below) |
|
||||
| **Ready for real work** | **≈ 38%** | estimated (dimension table below; weighted 37.7%) |
|
||||
@@ -59,7 +59,7 @@ mainstream and essentials numbers use multiplicative discounts.
|
||||
geometry kernel, DXF/DWG paths, interaction code. Plus the 88 GitHub issues and 47 open PRs.
|
||||
Disk was nearly full, so nothing was built or run: `cargo xtask parity` was reproduced by a
|
||||
Python scan of `CommandSpec` menus with the xtask's matching rule, which gives exactly the same
|
||||
uncovered list as the last generated report (233 / 491). Re-run `cargo xtask parity` to
|
||||
uncovered list as the last generated report (233 / 491 at the 2026-10-10 scan, a historical figure). Re-run `cargo xtask parity` to
|
||||
confirm.
|
||||
|
||||
## Mainstream practitioner
|
||||
@@ -147,14 +147,14 @@ presence: an option that prints "not available yet" doesn't count.
|
||||
|---|---:|---:|---|---|---|
|
||||
| Draw (2D) | 13% | 60% | 15–25 | ARC 11/11 methods, CIRCLE 6/6, RECTANG all options plus a centre construction mode (#1127), REVCLOUD, WIPEOUT, XLINE; PLINE arc options and SPLINE options done (#397, #408); MLINE, REGION and HELIX are objects (#510); isometric drafting (#556) | [gaps](gaps.md#features) |
|
||||
| Modify | 15% | 50% | 40–60 | TRIM/EXTEND, OFFSET, ROTATE, PEDIT and SPLINEDIT options done (#397, #558, #559); associative arrays with ARRAYEDIT (#448), no array grips; MATCHPROP picker and Settings (#439) | [gaps](gaps.md#features) |
|
||||
| Geometry precision | 10% | 40% | 70–110 | exact for lines/arcs/circles; ellipse and spline intersections, offsets, closest points tessellated; absolute tolerances; polyline offset doesn't recompute bulges | [geometry-parity](geometry-parity.md) |
|
||||
| Layers and properties | 7% | 70% | 10–15 | Layer Properties Manager with filters, states, VP overrides; QSELECT; Quick Properties; transparency; linetype manager thin; 19 linetypes of ~45 | [gaps](gaps.md#features) |
|
||||
| Geometry precision | 10% | 40% | 70–110 | exact for lines/arcs/circles; ellipse and spline intersections, offsets, closest points tessellated; absolute tolerances; polyline offset recomputes arc bulges since #825 but has no loop cleanup | [geometry-parity](geometry-parity.md) |
|
||||
| Layers and properties | 7% | 70% | 10–15 | Layer Properties Manager with filters, states, VP overrides; QSELECT; Quick Properties; object transparency drawn on screen, in exports and in plots (#764); linetype manager thin; 19 linetypes of ~45 | [gaps](gaps.md#features) |
|
||||
| Dimensions | 9% | 45% | 25–35 | all core DIM types and associativity; style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT, QDIM modes (#507) and TOLERANCE (#420); no DIMSTYLE manager dialog or smart DIM | [gaps](gaps.md#features) |
|
||||
| Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) |
|
||||
| Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) |
|
||||
| Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); in-place editor with formatting toolbar and ruler for new MTEXT (#792; existing TEXT is edited on the command line with TEXTEDIT; an existing MTEXT opens in the in-place editor with its contents, #1002); no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) |
|
||||
| Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); MLEADEREDIT, MLEADERALIGN and MLEADERCOLLECT (#816); tables with cell styles, formulas, breaking and CSV data links (#534); no FIELD (open PR #551) | [gaps](gaps.md#features) |
|
||||
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; XCLIP; no dynamic-block authoring, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT, REFSET and REFCLOSE (#818); XCLIP and XCLIPFRAME (#2354); dynamic block references read and edited, with grips (#838–#883); no dynamic-block authoring, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468), MVIEW Restore (#633); no system printers (CUPS printing is open PR #755) | [gaps](gaps.md#features) |
|
||||
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) |
|
||||
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) |
|
||||
| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) |
|
||||
@@ -168,12 +168,12 @@ presence: an option that prints "not available yet" doesn't count.
|
||||
| Features (table above) | 35% | 41% | 735–1,115 | estimated | this file |
|
||||
| UI/UX fidelity (point entry, snaps, tracking, dynamic input, grips, selection, command line, chrome) | 15% | 43% | 100–160 | estimated, weighted in the doc | [ui-parity](ui-parity.md) |
|
||||
| File formats (DWG/DXF fidelity first) | 20% | 30% | 170–280 | estimated | [file-format-parity](file-format-parity.md) |
|
||||
| Stability and correctness | 10% | 35% | 30–50 | estimated: about 60 bug fixes merged on 2026-10-10 alone, 30 open issues, `cargo fmt` failing on main (PR #194), no CI runs the Rust tests on pull requests | [gaps](gaps.md#stability) |
|
||||
| Stability and correctness | 10% | 35% | 30–50 | estimated: about 60 bug fixes merged on 2026-10-10 alone, 415 open issues (`gh issue list`, 2026-10-24), `cargo fmt` fixed on main, CI runs `cargo xtask ci` on every pull request (#1987) | [gaps](gaps.md#stability) |
|
||||
| Performance | 5% | 60% | 20–40 | internal numbers only (200k entities fluent); 1M entities and large DWG loads untested; a 10 MB DWG took ~2 min to fail (#57) | [hardware-parity](hardware-parity.md#performance-on-hardware-internal-numbers-only) |
|
||||
| Ecosystem and automation (AutoLISP/Visual LISP, .NET/ObjectARX, CUI, APPLOAD, Express Tools, App Store) | 5% | 10% | 120–200 | estimated; MCP, CLI and the control channel are ahead of AutoCAD, but no LISP, CUI or plug-ins | [gaps](gaps.md#ecosystem-and-automation) |
|
||||
| Ecosystem and automation (AutoLISP/Visual LISP, .NET/ObjectARX, CUI, APPLOAD, Express Tools, App Store) | 5% | 10% | 120–200 | estimated; MCP, CLI and the control channel are ahead of AutoCAD; AutoLISP core (#855) and SCRIPT (#693) landed, but no Visual LISP, CUI or plug-ins | [gaps](gaps.md#ecosystem-and-automation) |
|
||||
| Hardware | 3% | 45% | 15–25 | estimated | [hardware-parity](hardware-parity.md) |
|
||||
| Platforms | 3% | 75% | 15–25 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web: more than AutoCAD; but the web build can't open or save files reliably (#55, #133, #134) and has no DWG, and Windows/Linux get little runtime testing | [gaps](gaps.md#platforms) |
|
||||
| Localization | 3% | 5% | 60–100 | measured: string catalog with Ukrainian (936 rows, #36); none of AutoCAD for Mac's 7 other languages | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3% | 75% | 15–25 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web: more than AutoCAD; but the web build's plot shows raw PDF bytes (#134; open and save fixed, #55, #133) and it has no DWG, and Windows/Linux get little runtime testing | [gaps](gaps.md#platforms) |
|
||||
| Localization | 3% | 5% | 60–100 | string catalog with eighteen languages (Ukrainian 1,414 rows, #36; six of AutoCAD for Mac's 7 other languages, all but Japanese); the 5% and the hours are from 2026-10-10 and not re-scored | [localization-parity](localization-parity.md) |
|
||||
| AI features (Autodesk Assistant, Smart Blocks, Markup Assist) | 1% | 20% | 40–80 | none of AutoCAD's in-app AI; agent control over MCP is a different strength | [gaps](gaps.md#ai-features) |
|
||||
| **Ready for real work** | 100% | **≈ 38%** | **≈ 1,300–2,100** | | |
|
||||
|
||||
@@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-27 | minor | Blocks, attributes, xrefs, groups row: XCLIP and XCLIPFRAME clip block and xref references; parity percentages and hours not re-measured |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: evidence text of the feature, stability, platforms and localization rows refreshed against main 41d9b5e95; percentages, hours and breadth counts (245/491, 233/491) not re-measured |
|
||||
| 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured |
|
||||
| 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured |
|
||||
| 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |
|
||||
|
||||
+32
-27
@@ -1,17 +1,19 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure-aware grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure-aware grips; previously: re-audit of the evidence text against main 41d9b5e95: point entry, tracking, Dynamic Input, grips, scripts, views; percentages not re-measured; previously: distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
|
||||
How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic
|
||||
input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD
|
||||
comes from these, so "the command exists" isn't enough: it has to accept the same input, snap to
|
||||
the same points and respond to the same keys. Work items: [gaps.md](gaps.md#uiux).
|
||||
|
||||
**Headline (estimated): UI/UX fidelity ≈ 48%.** The command line, point syntax, the common
|
||||
running snaps, polar/ortho, object snap tracking and the palettes are real, and so are the
|
||||
Extension, Parallel and Apparent Intersection snaps (#379); dynamic input is a read-only
|
||||
tooltip; FROM, M2P, TK and point filters don't exist; hot-grip options are advertised but
|
||||
ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**.
|
||||
**Headline (estimated): UI/UX fidelity ≈ 48%.** The command line, point syntax (FROM, M2P, TK,
|
||||
point filters, typed snap overrides), the common running snaps, polar/ortho, PolarSnap, object snap
|
||||
tracking, Dynamic Input (pointer boxes, grip dimensional input, DYNPROMPT), hot-grip options with
|
||||
several hot grips, shortcut menus and the palettes are real, and so are the Extension, Parallel and
|
||||
Apparent Intersection snaps (#379). Selection cycling landed (#1143). Missing: lasso selection, grip hover menus,
|
||||
multifunctional grips on dimensions and text, workspaces and the navigation bar. The 48% and the area
|
||||
percentages below are from 2026-10-11 and were not re-measured. Remaining: **≈ 110–170 Opus 5.5 hours**.
|
||||
|
||||
## How this was measured
|
||||
|
||||
@@ -49,7 +51,7 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
|
||||
| Architectural/engineering distances `5'6-1/2"` | yes | yes | `units.rs:93-140` |
|
||||
| `FROM` (base point + offset) | yes | yes: "Base point:", "<Offset>:" with `@` measured from the base; nests with M2P and filters | `pointmod.rs` |
|
||||
| `M2P` / `MTP` (midpoint between two points) | yes | yes | `pointmod.rs` |
|
||||
| `TK` (tracking) | yes | **no** | |
|
||||
| `TK` (tracking) | yes | yes (`TK`, or `TRACKING` from three letters; `TT` is the temporary track point) | `pointmod.rs` |
|
||||
| Point filters `.x`, `.y`, `.xy`, `.z` | yes | yes (`.x` … `.yz`, "(need YZ):"); Z is asked for and dropped (no 3D) | `pointmod.rs` |
|
||||
| Angle override `<45` | yes | yes: `<a`, `<<a`, `<<<a`; a typed distance or a pick follows the angle, typed coordinates win | `pointmod.rs` |
|
||||
| Object snap overrides (`END`, `MID`, `INT` … `NON`) | yes | yes, every mode and `NON`, with long names and `_`/`'` prefixes, for the next pick only; a typed point is snapped within the aperture; a command's own keyword wins (`_cen` always snaps); also from the snap menu | `pointmod.rs` |
|
||||
@@ -73,8 +75,8 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
|
||||
| Nearest | every curve | yes |
|
||||
| Apparent intersection | projected intersection | yes in 2D: where objects near the cursor would meet if extended; an object rested on sends its whole extension, so its crossing with another object snaps too (`snap.rs` `osnap_with`, `snap/tracking.rs`) |
|
||||
| Parallel | yes, with parallel path | yes: rest on a line, then a path parallel to it runs through the base point (`snap/tracking.rs`) |
|
||||
| Temporary overrides typed at a prompt (`END`, `MID`, `INT` …) | yes | **no**: the text arrives as plain input |
|
||||
| Shift/Ctrl+right-click snap menu | yes | yes: From, Mid Between 2 Points, Point Filters, the snap modes, None, Osnap Settings; no Temporary track point yet (`context_menu.rs`) |
|
||||
| Temporary overrides typed at a prompt (`END`, `MID`, `INT` …) | yes | yes, see Point entry above (`pointmod.rs`) |
|
||||
| Shift/Ctrl+right-click snap menu | yes | yes: From, Mid Between 2 Points, Point Filters, Temporary track point, the snap modes, None, Osnap Settings (`context_menu.rs`) |
|
||||
| Tab to cycle candidate snaps | yes | yes, through the closest snap of each running mode under the cursor (`pointmod::snap_candidates`) |
|
||||
| AutoSnap marker, tooltip, magnet, aperture box | yes | marker per mode and name label; aperture from APERTURE; no magnet, no aperture box |
|
||||
| OSNAPZ, 3D object snaps | yes | no |
|
||||
@@ -85,8 +87,8 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
|
||||
|---|---|---|
|
||||
| Ortho (F8) | yes, Shift overrides it while held | yes; holding Shift turns it on or off while held (`Session::snap_cursor`, `snap/tracking.rs`) |
|
||||
| Polar tracking increment | yes | yes, measured from ANGBASE; dotted path and "Polar: d < a°" tooltip (`snap/tracking.rs`, `canvas.rs` `draw_tracking`) |
|
||||
| Additional polar angles, relative to last segment | yes | additional angles yes (POLARADDANG with POLARMODE 4); **relative to the last segment no** |
|
||||
| Polar distance snap (PolarSnap) | yes | **no** |
|
||||
| Additional polar angles, relative to last segment | yes | additional angles yes (POLARADDANG); relative to the last segment with POLARMODE bit 1 (`snap/tracking.rs`, #450) |
|
||||
| Polar distance snap (PolarSnap) | yes | yes: SNAPTYPE 1 steps along the tracking paths by POLARDIST (#450; also over the isometric snap, #633) (`snap/tracking.rs` `polar_snap`) |
|
||||
| **Object snap tracking** (acquire points, alignment paths, intersections of paths) | yes, F11 | yes (#379): resting on a snap point acquires it (`+`), resting again releases it, seven at most; horizontal/vertical paths, or every polar angle with POLARMODE 2; Shift to acquire with POLARMODE 8; crossings of two paths, a path and an object, and a path and a polar path snap; tooltip names each path; a typed distance (direct distance entry) runs along the path the cursor is on, measured from the point the path comes from. Engine: `Session::snap_cursor`, `snap/tracking.rs`; AUTOSNAP bits 8 and 16 |
|
||||
| Grid (rectangular, major lines, adaptive, limits) | yes | rectangular and isometric (30°, 90° and 150° lines), major lines, adaptive display |
|
||||
| Snap (rectangular, isometric, polar snap) | yes | rectangular and isometric (SNAPSTYL 1: the snap Y spacing along the isometric axes), from SNAPBASE turned by SNAPANG |
|
||||
@@ -103,9 +105,11 @@ CADCraft has pointer input boxes since #60 (`crates/ui-egui/src/dyninput.rs`): v
|
||||
beside the cursor while a command asks for a point; second and later points default to polar
|
||||
distance and angle (DYNPIFORMAT = 1 for Cartesian); `,` and `<` lock a value and move to the next
|
||||
box; Tab types the separator; Enter sends a relative entry (DYNPICOORDS = 1 for absolute) and
|
||||
fills an empty box from the cursor; a plain number stays direct distance entry. Missing:
|
||||
dimensional input on grips (length/angle fields while stretching), lock icons, the prompt with a
|
||||
down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
fills an empty box from the cursor; a plain number stays direct distance entry. Since then: grip
|
||||
dimensional input (length and angle fields while a grip is stretched, DYNDIVIS, DYNDIGRIP), lock
|
||||
indicators on locked values, and DYNPROMPT (the prompt beside the cursor, Down Arrow for its
|
||||
options); distances show in the drawing's UNITS (#867). Missing: DYNDIM (dimension display while
|
||||
dimensions are edited).
|
||||
|
||||
## Grips (≈ 40%)
|
||||
|
||||
@@ -114,11 +118,11 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout (one grip per logical corner, including coincident corners; an explicit closing marker follows the first vertex), spline (`grips.rs`) |
|
||||
| Text/MText grips | position, width, column grips | move the whole object |
|
||||
| Hot-grip modes with Space/Enter cycling | Stretch, Move, Rotate, Scale, Mirror | yes (`canvas.rs:58-67`) |
|
||||
| Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | **advertised in the prompt but not parsed**; the engine's JSON grip commands accept `copy`, the UI never passes it |
|
||||
| Several hot grips at once (Shift-click) | yes | **no** (one handle) |
|
||||
| Multifunctional grip hover menus (Add Vertex, Remove Vertex, Convert to Arc, Lengthen …) | yes | **no** |
|
||||
| Grip dimensional input (length/angle fields) | yes | no |
|
||||
| Dynamic block grips | yes | no (no dynamic blocks) |
|
||||
| Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | yes (`cmd/griphot.rs`, #425) |
|
||||
| Several hot grips at once (Shift-click) | yes | yes; STRETCH moves them together (`cmd/griphot.rs`) |
|
||||
| Multifunctional grip hover menus (Add Vertex, Remove Vertex, Convert to Arc, Lengthen …) | yes | on right-click only, not on hover (`grips.rs`, #425); none on dimensions, MLEADER, hatches or text |
|
||||
| Grip dimensional input (length/angle fields) | yes | yes while one grip is stretched: DYNDIVIS and DYNDIGRIP choose the fields (`grip_dims.rs`) |
|
||||
| Dynamic block grips | yes | yes: arrows, squares and list triangles per visibility state, dragged to change the parameter (#883) |
|
||||
| Grip appearance (GRIPSIZE, colours, hover) | yes | GRIPSIZE, GRIPS and GRIPOBJLIMIT honoured (Options ▸ Selection); polyline segment midpoints use rectangles, vertices use squares; no grip colours |
|
||||
|
||||
## Selection (≈ 40%)
|
||||
@@ -148,18 +152,18 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Enter/Space repeats last command | yes | yes |
|
||||
| Clickable keywords in the prompt | yes | yes (`cmdline.rs:253-267`) |
|
||||
| UNDO with Mark, Back, BEgin, End, Auto, Control | yes | count only, whole-document snapshots capped at 2,000 (`lib.rs:477`, `edit.rs:56`) |
|
||||
| SETVAR for every system variable | ~1,000 sysvars | about 60 session/header variables by name (`sysvars.rs`) |
|
||||
| Scripts (SCRIPT, `.scr`) | yes | script text via CLI/MCP/control; no `.scr` file command |
|
||||
| SETVAR for every system variable | ~1,000 sysvars | about 60 session/header variables by name (`sysvars.rs`, counted 2026-10-10); [gaps.md](gaps.md) U10 counts about 150 with the dimension variables; not recounted |
|
||||
| Scripts (SCRIPT, `.scr`) | yes | the SCRIPT command runs a `.scr` file (#693); script text via CLI/MCP/control too |
|
||||
| Floating/docked command window, transparency, lines of history | yes | floating overlay, history lines setting |
|
||||
|
||||
## Chrome and palettes (≈ 45%)
|
||||
|
||||
| Feature | AutoCAD for Mac | CADCraft |
|
||||
|---|---|---|
|
||||
| Menu bar | 11 menus, 491 leaf items | same 11 menus; 245/491 items covered (233 by `cargo xtask parity`, which counts engine commands only, plus 12 UI-only items: see [parity-checklist.md](parity-checklist.md)); once a menu is open, pointing at another title opens that menu (#1567) |
|
||||
| Menu bar | 11 menus, 491 leaf items | same 11 menus; 245/491 items covered at the 2026-10-10 re-measure, not re-measured since (the generated checklist now reads 298/491, engine commands only; 233 by `cargo xtask parity`, which counts engine commands only, plus 12 UI-only items: see [parity-checklist.md](parity-checklist.md)); once a menu is open, pointing at another title opens that menu (#1567) |
|
||||
| Tool Bar, File tabs, Start tab, layout tabs | yes | yes |
|
||||
| Tool Sets palette (Drafting, Modeling, … with flyouts) | yes | yes for 2D groups |
|
||||
| Properties Inspector, Quick Properties | yes | yes (every control acts or is visibly unavailable, #182); block references show read-only Position X/Y/Z from their stored insertion point; coordinate editing is still missing |
|
||||
| Properties Inspector, Quick Properties | yes | yes (every control acts or is visibly unavailable, #182); block references show read-only Position X/Y/Z from their stored insertion point; coordinate editing is still missing; numeric fields take feet and inches and show lengths in the drawing's units (#870) |
|
||||
| Layer Properties Manager (filters, states, VP overrides) | yes | yes |
|
||||
| Blocks palette, Content | yes | Blocks window (click to insert, #28) |
|
||||
| Status bar toggles | ~25 | most toggles; Annotation Visibility and Workspace do nothing |
|
||||
@@ -177,12 +181,12 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Feature | AutoCAD | CADCraft |
|
||||
|---|---|---|
|
||||
| ZOOM Extents, All, Window, Previous, In/Out, Center, Scale, Object | yes | yes, 50-entry history (`cmd/view.rs:107-149`) |
|
||||
| ZOOM Realtime (drag), Dynamic | yes | **no** |
|
||||
| Wheel zoom about cursor, ZOOMFACTOR, ZOOMWHEEL | yes | fixed factor (no ZOOMFACTOR) |
|
||||
| ZOOM Realtime (drag), Dynamic | yes | yes (`zoom.realtime`, `zoom.dynamic`, `cmd/view.rs`) |
|
||||
| Wheel zoom about cursor, ZOOMFACTOR, ZOOMWHEEL | yes | ZOOMFACTOR is a setting (`Session::zoomfactor`, 3–100, default 60); ZOOMWHEEL not checked |
|
||||
| Middle-drag pan, double-middle-click extents | yes | yes (`canvas.rs:538-583`) |
|
||||
| Trackpad two-finger pan, pinch zoom (macOS) | yes | yes (#58) |
|
||||
| Named views (VIEW) | yes | **no** (VIEW table not read or written) |
|
||||
| Tiled model-space viewports (VPORTS) | yes | not confirmed: VPORTS maps to paper-space MVIEW |
|
||||
| Named views (VIEW) | yes | yes (`cmd/view/named_views.rs`; the VIEW table is read and written); Orthographic views print "not available yet" |
|
||||
| Tiled model-space viewports (VPORTS) | yes | yes, with saved configurations and the DXF VPORT table (#504); MVIEW Restore puts one into a layout (#633) |
|
||||
| 3D orbit, ViewCube drag, SteeringWheels | yes | no (M11) |
|
||||
|
||||
## Keyboard and context menus (≈ 25%)
|
||||
@@ -206,6 +210,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
|---|---|---|
|
||||
| 2026-10-27 | minor | WIPEOUT grips treat implicit and explicit closure alike; moving the first vertex carries the closing marker, and moving endpoints together keeps both corners editable. Open/closed polyline flags and coincident polyline vertices remain independent (#2256) |
|
||||
| 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. |
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: evidence text for TK and typed snap overrides, PolarSnap, Dynamic Input, hot-grip options, dynamic block grips, SCRIPT, ZOOM Realtime/Dynamic, named views and VPORTS; headline no longer lists selection cycling as missing; the 48% and the area percentages are not re-measured |
|
||||
| 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) |
|
||||
| 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions |
|
||||
| 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |
|
||||
|
||||
@@ -159,6 +159,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the app's open size limit and a larger drawing is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `cadcraft:ready`, `cadcraft:open`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
### Driving it from the page (`window.cadcraft`)
|
||||
|
||||
Every CADCraft web build puts `window.cadcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `cadcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app drops it unrun if it had not started it.
|
||||
|
||||
```js
|
||||
const drawing = await cadcraft.docInspect(); // counts, extents, layers, styles, selection
|
||||
await cadcraft.execute("ui.theme", { theme: "dark" });
|
||||
const { data } = await cadcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a drawing, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
CADCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
+4
-14
@@ -136,18 +136,6 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::{Counts, count_tests};
|
||||
|
||||
/// A new private directory in the temp folder. It is created, never reused: an existing path
|
||||
/// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes.
|
||||
fn fresh_dir(tag: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id()));
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b.create(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
@@ -159,7 +147,8 @@ mod tests {
|
||||
#[test]
|
||||
fn walk_does_not_follow_directory_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = fresh_dir("xtask");
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let real = base.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
@@ -173,7 +162,8 @@ mod tests {
|
||||
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
|
||||
#[test]
|
||||
fn walk_reports_budget_exhaustion() {
|
||||
let base = fresh_dir("stats-budget");
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
|
||||
assert!(error.contains("budget exhausted"));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Test builds must not reintroduce the DWG decoder on targets where the bridge excludes it.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn dwg_test_dependency_matches_the_bridge_targets() {
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.output()
|
||||
.expect("read workspace dependency metadata");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).expect("Cargo returns JSON");
|
||||
let packages = metadata["packages"].as_array().expect("workspace packages");
|
||||
let dependency = |name: &str| {
|
||||
let package = packages.iter().find(|p| p["name"] == name).expect("workspace crate");
|
||||
package["dependencies"].as_array().expect("crate dependencies").iter().find(|d| d["name"] == "acadrust").expect("DWG decoder dependency")
|
||||
};
|
||||
let bridge = dependency("cadcraft-dwg");
|
||||
let tests = dependency("cadcraft-io");
|
||||
assert_eq!(tests["kind"], "dev", "the IO crate only inspects acadrust output in tests");
|
||||
assert_eq!(tests["target"], bridge["target"], "--all-targets must not add acadrust where the native DWG bridge is unavailable");
|
||||
assert_eq!(tests["req"], bridge["req"], "tests must inspect the same decoder version as the bridge");
|
||||
}
|
||||
Reference in New Issue
Block a user