From cda7381c3df4b70abf2a246420ec23d754badf7d Mon Sep 17 00:00:00 2001 From: Sorin Date: Sun, 18 Oct 2026 13:17:25 +0300 Subject: [PATCH 01/91] Keep rectangle opposite corners free of ortho (STRETCH window and friends) With ORTHOMODE on, the opposite corner of STRETCH's crossing window was bent onto an axis, so the window collapsed and caught nothing. The prompt set only the rubber-band base; it now marks it as a window corner (Prompt::corner), as ZOOM Window and RECTANG already do. Same fix for the other two-corner rectangles: the Window/Crossing/Box selection modes, PLOTWINDOW, MVIEW (and its Restore), -VIEW Window and REVCLOUD Rectangular. Regression tests in crates/engine/tests/stretch_window_ortho.rs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/draw2.rs | 4 +- crates/engine/src/cmd/layout/viewports.rs | 4 +- crates/engine/src/cmd/modify.rs | 2 +- crates/engine/src/cmd/plotters.rs | 2 +- crates/engine/src/cmd/view/named_views.rs | 2 +- crates/engine/src/select.rs | 2 +- crates/engine/tests/stretch_window_ortho.rs | 79 +++++++++++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/stretch_window_ortho.rs diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index fca1c6611..24fe48636 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1057,7 +1057,9 @@ impl Interactive for RevcloudM { let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"]; match (self.kind, self.pts.len()) { (CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"), - (CloudKind::Rect, _) => Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()), + (CloudKind::Rect, _) => { + Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) } + } (CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"), (CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), (CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs index 7f47c4e31..e82d881fc 100644 --- a/crates/engine/src/cmd/layout/viewports.rs +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -637,13 +637,13 @@ impl Interactive for MviewM { Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW) .kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"]) .default("Right"), - Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Polygon(pi) => pi.prompt(), Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT), Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]), Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE), Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"), - Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), + Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(*a), Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 37533b13c..3ffb110c9 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -486,7 +486,7 @@ impl Interactive for StretchM { return m.prompt(); } match (self.phase, self.corner) { - (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c), + (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c), (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 454b977ae..762f65066 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -365,7 +365,7 @@ impl Interactive for PlotWindowM { fn prompt(&self, _s: &Session) -> Prompt { match self.first { None => Prompt::new("Specify first corner", Accept::POINT), - Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs index 40c25632b..ef2750ef2 100644 --- a/crates/engine/src/cmd/view/named_views.rs +++ b/crates/engine/src/cmd/view/named_views.rs @@ -279,7 +279,7 @@ impl Interactive for ViewM { Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT), Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT), Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT), - Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a), + Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a), Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]), Stage::Snapshot => { let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" }; diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 271e1631d..0f31837ed 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -419,7 +419,7 @@ impl SelectMode { }; match last { None => Prompt::new(first, Accept::POINT), - Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).base(p), + Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p), Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p), } } diff --git a/crates/engine/tests/stretch_window_ortho.rs b/crates/engine/tests/stretch_window_ortho.rs new file mode 100644 index 000000000..70a7fdbfa --- /dev/null +++ b/crates/engine/tests/stretch_window_ortho.rs @@ -0,0 +1,79 @@ +//! STRETCH's crossing window ignores ortho: the opposite corner is where the cursor is, so the +//! window keeps its area and catches what it crosses. The same holds for every rectangle picked by +//! two corners (PLOTWINDOW, MVIEW, -VIEW Window, REVCLOUD Rectangular). + +use cadcraft_engine::snap::tracking::CursorQuery; +use cadcraft_engine::{Input, Session}; +use cadcraft_geom::Vec2; +use serde_json::json; + +/// Move the cursor to `raw` at the current prompt, as the canvas does. +fn hover(s: &mut Session, raw: Vec2) -> Vec2 { + let p = s.current_prompt().unwrap(); + let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }; + let c = s.snap_cursor(raw, &q).point; + s.cursor = c; + c +} + +/// A session with ortho on, object snaps off and a line from 0,0 to 10,0. +fn ortho_session() -> Session { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s +} + +/// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents. +fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) { + assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner"); + let c = hover(&mut s, to); + assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}"); + s.input(Input::Point(c)).unwrap(); + s.input(Input::Enter).unwrap(); + for t in ["0,0", "5,0"] { + s.cmdline(t).unwrap(); + } + assert!(s.current_prompt().is_none(), "STRETCH is done, still at {:?}", s.current_prompt().map(|p| p.message)); + let b = s.doc().unwrap().extents(&s.space()); + (b.min, b.max) +} + +#[test] +fn stretch_implied_window_ignores_ortho() { + let mut s = ortho_session(); + s.cmdline("stretch").unwrap(); + // Right to left: a crossing window. + s.input(Input::Point(Vec2::new(12.0, 1.0))).unwrap(); + assert_eq!(stretch_from_corner(s, Vec2::new(8.0, -1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn stretch_crossing_mode_ignores_ortho() { + let mut s = ortho_session(); + for t in ["stretch", "c", "8,-1"] { + s.cmdline(t).unwrap(); + } + assert_eq!(stretch_from_corner(s, Vec2::new(12.0, 1.0)), (Vec2::ZERO, Vec2::new(15.0, 0.0)), "the line's end moved by 5"); +} + +#[test] +fn rectangle_corner_prompts_ignore_ortho() { + for script in [&["plotwindow", "0,0"][..], &["mview", "0,0"], &["-view", "w", "v1", "0,0"], &["revcloud", "r", "0,0"]] { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "OSMODE", "value": 0 })).unwrap(); + s.execute("setvar", &json!({ "name": "ORTHOMODE", "value": 1 })).unwrap(); + if script[0] == "mview" { + s.cmdline("layout s Layout1").unwrap(); + } + for t in script { + s.cmdline(t).unwrap(); + } + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Specify opposite corner", "{script:?}"); + assert!(p.corner, "{script:?}: the opposite corner ignores ortho"); + let c = hover(&mut s, Vec2::new(10.0, 4.0)); + assert!(c.near(Vec2::new(10.0, 4.0), 1e-9), "{script:?}: the corner is where the cursor is: {c:?}"); + } +} From d0e83aefa6bf61a6f52048305d0f3172c33810ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:02:24 +0000 Subject: [PATCH 02/91] chore(deps): bump lyon_tessellation Bumps the cargo-dependencies group with 1 update in the / directory: [lyon_tessellation](https://github.com/nical/lyon). Updates `lyon_tessellation` from 1.0.22 to 1.0.23 - [Release notes](https://github.com/nical/lyon/releases) - [Commits](https://github.com/nical/lyon/commits) --- updated-dependencies: - dependency-name: lyon_tessellation dependency-version: 1.0.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5076e82e..266e27ca9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2212,9 +2212,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" dependencies = [ "float_next_after", "lyon_path", From 4aed7a2267bd96591803e4b72e9ddb26245c51e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:19:36 -0700 Subject: [PATCH 03/91] COMPAREEXPORT: export a drawing comparison to a new drawing COMPAREEXPORT compares the two drawings again and opens the result as a new untitled drawing, compare_ vs .dwg. Model space holds the other drawing's removed and changed objects as group COMPARE_COMPARED and the compared tab's objects as group COMPARE_CURRENT, in the overlay's colors: green for objects only in this drawing or changed in it, red for the other drawing's, gray for objects in both. Block references, dimensions and multileaders refer to recolored copies of their blocks, tables and tolerances use recolored copies of their styles, and inline MTEXT and cell colors, dimension style colors, multiline element colors and gradients follow the object. The new drawing takes the layers and styles of the other drawing that this drawing lacks, and the blocks its exported objects refer to; an anonymous block whose name is taken is copied under a free name. The comparison bar gets an Export button. --- crates/engine/src/cmd/file/compare.rs | 11 +- crates/engine/src/cmd/file/compare/export.rs | 600 +++++++++++++++++++ crates/engine/tests/compare_export.rs | 446 ++++++++++++++ crates/ui-egui/src/canvas/compare.rs | 3 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + docs/gaps.md | 5 +- docs/target-app-parity.md | 5 +- 18 files changed, 1076 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/file/compare/export.rs create mode 100644 crates/engine/tests/compare_export.rs diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 15fb17ec1..2003d2079 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -2,7 +2,7 @@ //! drawing). Objects are matched by geometry and properties first (preferring the same handle), //! then by handle: the same handle with other geometry or properties is a change. What is left //! is only in this drawing or only in the other. The comparison stays on the session for the -//! canvas overlay and next/previous navigation until COMPARECLOSE. +//! canvas overlay, next/previous navigation and COMPAREEXPORT until COMPARECLOSE. use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -16,6 +16,8 @@ use super::super::*; use super::{base64_decode, io}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +mod export; + /// Objects compared per drawing; more are left out (and the result says so). pub const MAX_OBJECTS: usize = 200_000; /// Objects listed per kind of difference in the JSON result. @@ -36,6 +38,9 @@ pub struct Comparison { pub other_name: String, /// The other drawing (its objects are drawn where they differ). pub other: Arc, + /// The other drawing's file, when it has one: COMPAREEXPORT resolves its relative xref + /// paths from that file's folder. + pub other_path: Option, /// Only in this drawing (handles here). pub added: Vec, /// Only in the other drawing (handles there). @@ -71,6 +76,7 @@ pub fn specs() -> Vec { CommandSpec::new("compare.next", "Next Change", |s, _| step(s, 1)).params("→ {region, of}: zoom to the next area with changes").noundo(), CommandSpec::new("compare.previous", "Previous Change", |s, _| step(s, -1)).params("→ {region, of}").noundo(), CommandSpec::new("compareclose", "Close Comparison", run_close).params("{}: end the comparison and its overlay").noundo(), + export::spec(), ] } @@ -311,6 +317,7 @@ fn listed(d: &Drawing, hs: &[Handle]) -> Vec { fn run_compare(s: &mut Session, p: &Value) -> Result { let (other, name) = other_drawing(s, p)?; + let other_path = export::source_path(s, p, &other); let st = s.state()?; let (uid, space, cur) = (st.uid, st.space.clone(), st.doc.clone()); if Arc::ptr_eq(&cur, &other) { @@ -361,7 +368,7 @@ fn run_compare(s: &mut Session, p: &Value) -> Result { "message": message, }); let changed = changed.into_iter().map(|(a, b, _, _)| (a, b)).collect(); - s.compare = Some(Comparison { uid, space, other_name: name, other, added, removed, changed, common, truncated, regions, at: None }); + s.compare = Some(Comparison { uid, space, other_name: name, other, other_path, added, removed, changed, common, truncated, regions, at: None }); s.touch(); Ok(out) } diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs new file mode 100644 index 000000000..2ebae8b14 --- /dev/null +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -0,0 +1,600 @@ +//! COMPAREEXPORT: the comparison as a new drawing (a snapshot). The drawings are compared again +//! when the command runs, so edits made since COMPARE count. The new drawing's model space holds +//! copies of the other drawing's objects that differ, then copies of the current drawing's +//! objects in the compared tab, as two groups, `COMPARE_COMPARED` and `COMPARE_CURRENT`. Objects +//! only in this drawing or changed in it are drawn in the overlay's green, objects only in the +//! other drawing or changed there in its red, and objects in both in gray (color 8). A block +//! reference, dimension or multileader refers to a copy of its block whose objects all have that +//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`), and a +//! table or tolerance uses a recolored copy of its style; xrefs keep their own colors. The new +//! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other +//! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of +//! the other drawing whose name this one uses is copied under a free name. Relative xref, image +//! and data link paths become absolute paths from the folder of the drawing they came from. It +//! opens untitled as `compare_ vs .dwg`, with unsaved changes; saving it asks for a +//! file name. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use cadcraft_doc::color::{Color, Rgb}; +use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle}; +use serde_json::{Value, json}; + +use super::super::super::{CommandSpec, bad}; +use super::{Comparison, MAX_OBJECTS}; +use crate::{Result, Session}; + +/// Only in this drawing, or changed in it (the overlay's color). +const THIS: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b)); +/// Only in the other drawing, or changed there. +const OTHER: Color = Color::True(Rgb(0xff, 0x5c, 0x5c)); +/// In both drawings. +const COMMON: Color = Color::Index(8); +/// Block definitions copied from the other drawing (the ones they reference included), and +/// recolored copies of blocks and styles, per export. +const MAX_BLOCKS: usize = 10_000; +/// Objects copied into block definitions (the other drawing's blocks and the recolored +/// copies), per export. +const MAX_COPIED: usize = 1_000_000; + +pub(super) fn spec() -> CommandSpec { + CommandSpec::new("compareexport", "Export Comparison", run) + .params( + "{} → {index, title, current, compared: group names, counts: {current, compared}, truncated, message}: the comparison \ + as a new drawing, opened untitled", + ) + .noundo() +} + +/// What [`snapshot`] made: the drawing, the two group names, how many objects each holds, +/// whether a drawing had more objects than were compared, whether the other drawing had more +/// blocks to copy than [`MAX_BLOCKS`], and whether a block was left in its own colors. +struct Snapshot { + drawing: Drawing, + current: String, + compared: String, + counts: (usize, usize), + truncated: bool, + blocks_left_out: bool, + uncolored: bool, +} + +fn run(s: &mut Session, _p: &Value) -> Result { + let st = s.state()?; + let (uid, cur, title, cur_folder) = (st.uid, st.doc.clone(), st.title.clone(), folder_of(st.path.as_deref())); + let c = s.compare.as_ref().filter(|c| c.uid == uid).ok_or_else(|| bad("compareexport", "no comparison: run COMPARE first"))?; + let name = format!("compare_{} vs {}.dwg", stem(&title), stem(&c.other_name)); + let folders = Folders { here: cur_folder, there: folder_of(c.other_path.as_deref()) }; + let Snapshot { drawing, current, compared, counts, truncated, blocks_left_out, uncolored } = snapshot(&cur, c, &folders); + let i = s.open_drawing(drawing, &name, None); + // Not saved yet: closing it asks to save it under a name. + let st = s.state_mut()?; + st.saved = Arc::new((*st.doc).clone()); + let mut notes = Vec::new(); + if truncated { + notes.push(format!("only the first {MAX_OBJECTS} objects of each drawing were compared")); + } + if blocks_left_out { + notes.push("some blocks of the other drawing were too many or too large to copy".to_string()); + } + if uncolored { + notes.push("some blocks keep their own colors".to_string()); + } + let notes = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }; + Ok(json!({ + "index": i, + "title": name, + "current": current, + "compared": compared, + "counts": { "current": counts.0, "compared": counts.1 }, + "truncated": truncated || blocks_left_out || uncolored, + "message": format!("The comparison was exported to the new drawing {name}{notes}."), + })) +} + +/// The file of the drawing COMPARE compares with: the `path` it was read from, or the open +/// drawing's own file. `None` for `{data}` and for an open drawing never saved. +pub(super) fn source_path(s: &Session, p: &Value, other: &Arc) -> Option { + match p.get("path").and_then(Value::as_str) { + Some(path) if p.get("document").is_none_or(Value::is_null) => Some(path.to_string()), + _ => s.docs.iter().find(|d| Arc::ptr_eq(&d.doc, other)).and_then(|d| d.path.clone()), + } +} + +/// The folders of the two drawings' files. The snapshot has no folder of its own, so the +/// relative file paths it copies (xrefs, images, table data links) become absolute paths from +/// the folder of the drawing they came from. +struct Folders { + here: Option, + there: Option, +} + +/// The absolute folder of the file at `path`. +fn folder_of(path: Option<&str>) -> Option { + let parent = Path::new(path?).parent()?; + let parent = if parent.as_os_str().is_empty() { Path::new(".") } else { parent }; + Some(std::path::absolute(parent).unwrap_or_else(|_| parent.to_path_buf())) +} + +/// `p` as an absolute path from `folder` when it is relative. +fn rebase_path(p: &mut String, folder: Option<&Path>) { + if let Some(folder) = folder + && !p.trim().is_empty() + && Path::new(p.as_str()).is_relative() + { + let joined = folder.join(p.as_str()); + *p = std::path::absolute(&joined).unwrap_or(joined).to_string_lossy().to_string(); + } +} + +/// Rebase the file an object refers to: an image's file, a table's data link. +fn rebase(k: &mut EntityKind, folder: Option<&Path>) { + match k { + EntityKind::Image(i) => rebase_path(&mut i.path, folder), + EntityKind::Table(t) => { + if let Some(l) = t.data_link.as_mut() { + rebase_path(&mut l.path, folder); + } + } + _ => {} + } +} + +/// Rebase a block's xref path and the files its objects refer to. +fn rebase_block(b: &mut Block, folder: Option<&Path>) { + if let Some(x) = b.xref_path.as_mut() { + rebase_path(x, folder); + } + let mut store = EntityStore::new(); + for e in b.entities.iter() { + let mut e = (**e).clone(); + rebase(&mut e.kind, folder); + store.push(e); + } + b.entities = store; +} + +/// Whether a block holds a relative file path that [`rebase_block`] would change. +fn has_relative_paths(b: &Block) -> bool { + let rel = |p: &str| !p.trim().is_empty() && Path::new(p).is_relative(); + b.xref_path.as_deref().is_some_and(rel) + || b.entities.iter().any(|e| match &e.kind { + EntityKind::Image(i) => rel(&i.path), + EntityKind::Table(t) => t.data_link.as_ref().is_some_and(|l| rel(&l.path)), + _ => false, + }) +} + +/// A drawing title without its file extension. +fn stem(title: &str) -> String { + std::path::Path::new(title).file_stem().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| title.to_string()) +} + +fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot { + let other: &Drawing = &c.other; + let mut d = cur.clone(); + if folders.here.is_some() { + for b in d.blocks.values_mut().filter(|b| has_relative_paths(b)) { + rebase_block(Arc::make_mut(b), folders.here.as_deref()); + } + } + d.model = EntityStore::new(); + for l in &mut d.layouts { + l.entities = EntityStore::new(); + } + d.foreign = Arc::default(); + d.groups.clear(); + d.constraints.clear(); + d.hidden_objects.clear(); + d.read_notes.clear(); + d.refedit = None; + d.skipped_records.clear(); + d.handseed = d.handseed.max(other.handseed); + + merge(&mut d.layers, &other.layers, |t| &t.name); + merge(&mut d.linetypes, &other.linetypes, |t| &t.name); + merge(&mut d.text_styles, &other.text_styles, |t| &t.name); + merge(&mut d.dim_styles, &other.dim_styles, |t| &t.name); + merge(&mut d.mleader_styles, &other.mleader_styles, |t| &t.name); + merge(&mut d.table_styles, &other.table_styles, |t| &t.name); + merge(&mut d.mline_styles, &other.mline_styles, |t| &t.name); + + // Compared again, so edits made since COMPARE ran are classified as they are now. + let diff = super::diff(cur, other, &c.space); + let ours: HashSet = diff.added.iter().copied().chain(diff.changed.iter().map(|(a, ..)| *a)).collect(); + let here: Vec<(&Entity, Color)> = cur + .space(&c.space) + .into_iter() + .flat_map(EntityStore::iter) + .filter(|e| !matches!(e.kind, EntityKind::Viewport(_))) + .take(MAX_OBJECTS) + .map(|e| (e.as_ref(), if ours.contains(&e.handle) { THIS } else { COMMON })) + .collect(); + // In the other drawing's draw order. + let theirs: HashSet = diff.removed.iter().copied().chain(diff.changed.iter().map(|(_, b, ..)| *b)).collect(); + let there: Vec<(&Entity, Color)> = other + .space(&c.space) + .into_iter() + .flat_map(EntityStore::iter) + .filter(|e| theirs.contains(&e.handle) && !matches!(e.kind, EntityKind::Viewport(_))) + .map(|e| (e.as_ref(), OTHER)) + .collect(); + let mut tinted = Tinted::default(); + let (renames, blocks_left_out) = copy_blocks(&mut d, other, there.iter().map(|(e, _)| *e), folders.there.as_deref(), &mut tinted); + + // The other drawing's objects first, so this drawing's are drawn over them. + let compared = add_group(&mut d, "COMPARE_COMPARED", &there, &renames, folders.there.as_deref(), &mut tinted); + let current = add_group(&mut d, "COMPARE_CURRENT", &here, &Renames::new(), folders.here.as_deref(), &mut tinted); + let uncolored = tinted.uncolored; + Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored } +} + +/// Add the entries of `from` whose names `to` lacks (compared without letter case). +fn merge(to: &mut Vec, from: &[T], name: impl Fn(&T) -> &String) { + for t in from { + if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) { + to.push(t.clone()); + } + } +} + +/// Add copies of `objs` to model space with new handles, their block references renamed by +/// `renames` and their files rebased on `folder`, each painted in its comparison color, and +/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name. +fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String { + let mut members = Vec::new(); + for (e, color) in objs { + d.ensure_layer(&e.common.layer); + let h = d.new_handle(); + let mut copy = Entity { handle: h, ..(*e).clone() }; + detach(&mut copy.kind); + rebase(&mut copy.kind, folder); + rename_refs(&mut copy.kind, renames); + paint(d, &mut copy, *color, tinted, 0); + d.model.push(copy); + members.push(h); + } + let name = free_name(name, d.groups.len(), |n| d.groups.iter().any(|g| g.name.eq_ignore_ascii_case(n))); + if !members.is_empty() { + d.groups.push(Group { name: name.clone(), description: String::new(), selectable: true, members }); + } + name +} + +/// Drop the links of an associative dimension or hatch to the objects it follows: the copies +/// have new handles, so the links would name objects the snapshot does not have. +fn detach(k: &mut EntityKind) { + match k { + EntityKind::Dimension(dm) => dm.assoc.clear(), + EntityKind::Hatch(h) => { + h.associative = false; + h.loops.iter_mut().for_each(|l| l.sources.clear()); + } + _ => {} + } +} + +/// The block names an object refers to. +fn block_refs(k: &EntityKind) -> Vec { + match k { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::Dimension(dm) => dm.block.iter().cloned().collect(), + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + } +} + +/// Call `f` on each block name an object refers to. +fn each_ref(k: &mut EntityKind, mut f: impl FnMut(&mut String)) { + match k { + EntityKind::Insert(i) => f(&mut i.block), + EntityKind::Dimension(dm) => { + if let Some(n) = dm.block.as_mut() { + f(n); + } + } + EntityKind::MLeader(m) => { + if let Some(b) = m.block.as_mut() { + f(&mut b.block); + } + } + _ => {} + } +} + +/// Point the block references of an object at the names its blocks were copied under. +fn rename_refs(k: &mut EntityKind, renames: &Renames) { + each_ref(k, |n| { + if let Some(new) = renames.get(&n.to_ascii_uppercase()) { + n.clone_from(new); + } + }); +} + +/// Recolored copies of blocks and styles made so far, and whether a block was left in its own +/// colors because the nesting or copy limit was reached. +#[derive(Default)] +struct Tinted { + /// (comparison tag, "block", "table style" or "dimension style", upper-case name) → the + /// copy's name. + names: HashMap<(&'static str, &'static str, String), String>, + uncolored: bool, + /// Objects copied into block definitions so far, counted against [`MAX_COPIED`]. + copied: usize, +} + +/// The word a comparison color adds to the names of its recolored copies. +fn tag(color: Color) -> &'static str { + if color == THIS { + "CURRENT" + } else if color == OTHER { + "COMPARED" + } else { + "COMMON" + } +} + +/// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are. +fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String { + (1..=n + 1) + .map(|i| if i == 1 { base.to_string() } else { format!("{base}_{i}") }) + .find(|c| !taken(c)) + .unwrap_or_else(|| format!("{base}_{}", n + 2)) +} + +/// Give an object its comparison color: its own color, its attributes' colors, and, through +/// recolored copies of the blocks and styles it refers to, the color of everything they draw. +fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, depth: usize) { + e.common.color = color; + let attribs = match &mut e.kind { + EntityKind::Insert(i) => Some(&mut i.attribs), + EntityKind::MLeader(m) => m.block.as_mut().map(|b| &mut b.attribs), + _ => None, + }; + for a in attribs.into_iter().flatten() { + a.props.color = Some(color); + } + // Colors an object carries apart from its own: inline MTEXT colors, a dimension style's + // DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element colors, the style colors of + // a table or a tolerance (through a recolored copy of the style) and inline cell colors. Hatch + // backgrounds, multiline fills and table cell fills are left out. + match &mut e.kind { + EntityKind::MText(t) => t.contents = without_color_codes(&t.contents), + EntityKind::MLeader(m) => { + if let Some(t) = m.text.as_mut() { + t.contents = without_color_codes(&t.contents); + } + } + EntityKind::Dimension(dm) => { + if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { + for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { + dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); + dm.overrides.insert(var.into(), by_block.clone()); + } + } + } + EntityKind::Tolerance(t) => { + if let Some(style) = tinted_dim_style(d, &t.style, color, tinted) { + t.style = style; + } + } + EntityKind::Hatch(h) => { + if let Some(g) = h.gradient.as_mut() { + (g.color1, g.color2) = (color, color); + } + h.background = None; + } + EntityKind::MLine(m) => { + for el in &mut m.def.elements { + el.color = Color::ByBlock; + } + m.def.fill = None; + } + EntityKind::Table(t) => { + for cell in t.cells.iter_mut().flatten() { + cell.text = without_color_codes(&cell.text); + } + if let Some(style) = tinted_table_style(d, &t.style, color, tinted) { + t.style = style; + } + } + _ => {} + } + each_ref(&mut e.kind, |n| { + if let Some(copy) = tinted_block(d, n, color, tinted, depth) { + *n = copy; + } + }); +} + +/// The name of a copy of dimension style `name` that draws lines and text in the object's +/// color, made on first use. `None` when the drawing has no such style. +fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option { + let key = (tag(color), "dimension style", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); + let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.dim_styles.len(), |n| { + d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) + }); + d.dim_styles.push(DimStyle { + name: new.clone(), + dim_line_color: Color::ByBlock, + ext_line_color: Color::ByBlock, + text_color: Color::ByBlock, + ..st + }); + tinted.names.insert(key, new.clone()); + Some(new) +} + +/// The name of a copy of table style `name` whose cell styles draw text and borders in the +/// table's color and fill no cells, made on first use. `None` when the drawing has no such style. +fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted) -> Option { + let key = (tag(color), "table style", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); + let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.table_styles.len(), |n| { + d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) + }); + let names = + [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone())); + let mut cell_styles: Vec = Vec::new(); + for n in names { + if cell_styles.iter().any(|c| c.name.eq_ignore_ascii_case(&n)) { + continue; + } + let mut cs = st.cell_style(&n); + cs.name = n; + cs.text_color = Color::ByBlock; + cs.fill = None; + let b = &mut cs.borders; + for border in [&mut b.top, &mut b.inside_h, &mut b.bottom, &mut b.left, &mut b.inside_v, &mut b.right] { + border.color = Color::ByBlock; + } + cell_styles.push(cs); + } + d.table_styles.push(TableStyle { name: new.clone(), cell_styles, ..st }); + tinted.names.insert(key, new.clone()); + Some(new) +} + +/// MTEXT contents without inline color codes (`\C1;`, `\c255;`), so the text takes the +/// object's color. An escaped backslash (`\\`) stays. +fn without_color_codes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.peek().copied() { + Some('C' | 'c') => { + for d in it.by_ref() { + if d == ';' { + break; + } + } + } + Some(n) => { + out.push('\\'); + out.push(n); + it.next(); + } + None => out.push('\\'), + } + } + out +} + +/// The name of a copy of block `name` whose objects all have `color`, made on first use. `None` +/// (the reference keeps the original block) for an xref, a missing block, or past the nesting +/// and copy limits. +fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, depth: usize) -> Option { + let key = (tag(color), "block", name.to_ascii_uppercase()); + if let Some(n) = tinted.names.get(&key) { + return Some(n.clone()); + } + let b = d.block(name)?.clone(); + if b.xref_path.is_some() { + return None; + } + if depth >= MAX_BLOCK_DEPTH || tinted.names.len() >= MAX_BLOCKS || tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { + tinted.uncolored = true; + return None; + } + tinted.copied += b.entities.len(); + let new = free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()); + tinted.names.insert(key, new.clone()); + // Hold the name while the nested copies pick theirs. + d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); + let mut copy = Block { name: new.clone(), anonymous: false, entities: EntityStore::new(), ..(*b).clone() }; + for e in b.entities.iter() { + let h = d.new_handle(); + let mut e = Entity { handle: h, ..(**e).clone() }; + detach(&mut e.kind); + paint(d, &mut e, color, tinted, depth + 1); + copy.entities.push(e); + } + d.blocks.insert(new.clone(), Arc::new(copy)); + Some(new) +} + +/// The name each block copied from the other drawing has in the snapshot, by its upper-case +/// name there. +type Renames = HashMap; + +/// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks. +/// A named block `d` already has keeps `d`'s definition. An anonymous block (`*D3`, `*U7`…) +/// whose name `d` already uses is copied under the next free name with the same prefix. Copied +/// objects get new handles, and relative file paths are rebased on `folder`. The flag is true +/// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were +/// left out. +fn copy_blocks<'a>( + d: &mut Drawing, + other: &Drawing, + objs: impl Iterator, + folder: Option<&Path>, + tinted: &mut Tinted, +) -> (Renames, bool) { + let mut frontier: Vec = objs.flat_map(|e| block_refs(&e.kind)).collect(); + let mut seen = HashSet::new(); + let mut needed: Vec> = Vec::new(); + let mut left_out = false; + while let Some(n) = frontier.pop() { + let key = n.to_ascii_uppercase(); + if seen.contains(&key) { + continue; + } + if seen.len() >= MAX_BLOCKS { + left_out |= other.block(&n).is_some() && d.block(&n).is_none(); + continue; + } + seen.insert(key); + let Some(b) = other.block(&n) else { continue }; + if d.block(&n).is_some() && !b.anonymous { + continue; + } + if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { + left_out = true; + continue; + } + tinted.copied += b.entities.len(); + frontier.extend(b.entities.iter().flat_map(|e| block_refs(&e.kind))); + needed.push(b.clone()); + } + // Names `d` does not use stay; the others get free names, avoiding the kept ones. + let mut renames = Renames::new(); + let mut taken: HashSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + for b in needed.iter().filter(|b| d.block(&b.name).is_none()) { + taken.insert(b.name.to_ascii_uppercase()); + renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); + } + for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { + let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); + let free = (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())); + let Some(free) = free else { continue }; + taken.insert(free.to_ascii_uppercase()); + renames.insert(b.name.to_ascii_uppercase(), free); + } + for b in needed { + let Some(name) = renames.get(&b.name.to_ascii_uppercase()).cloned() else { continue }; + let mut copy = Block { name: name.clone(), entities: EntityStore::new(), ..(*b).clone() }; + for e in b.entities.iter() { + let h = d.new_handle(); + let mut e = Entity { handle: h, ..(**e).clone() }; + detach(&mut e.kind); + rename_refs(&mut e.kind, &renames); + copy.entities.push(e); + } + rebase_block(&mut copy, folder); + d.blocks.insert(name, Arc::new(copy)); + } + (renames, left_out) +} diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs new file mode 100644 index 000000000..76e9ee9e8 --- /dev/null +++ b/crates/engine/tests/compare_export.rs @@ -0,0 +1,446 @@ +//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and +//! the other drawing's differing objects in another, in the comparison colors. + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::color::{Color, Rgb}; +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Space}; +use serde_json::json; + +fn insert(block: &str) -> Insert { + Insert { + block: block.into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + } +} + +fn line(x: f64) -> EntityKind { + EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) }) +} + +fn handle_of(s: &Session, ty: &str) -> String { + s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex() +} + +/// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text +/// is gone and an arc was added. The comparison runs in "B" against "Drawing1". +fn compared() -> Session { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); + s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap(); + let d: Drawing = s.doc().unwrap().clone(); + s.open_drawing(d, "B.dxf", None); + let circle = handle_of(&s, "Circle"); + let text = handle_of(&s, "Text"); + s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap(); + s.execute("erase", &json!({"handles": [text]})).unwrap(); + s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap(); + s.execute("compare", &json!({"document": "Drawing1"})).unwrap(); + s +} + +/// The objects of a group of the active drawing, in group order. +fn members(s: &Session, group: &str) -> Vec> { + let d = s.doc().unwrap(); + let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}")); + g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect() +} + +/// The (type, color) of each object in a group of the active drawing, sorted. +fn contents(s: &Session, group: &str) -> Vec<(String, Color)> { + let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v +} + +/// The x of each line's end in a block of the active drawing. +fn ends(s: &Session, block: &str) -> Vec { + let d = s.doc().unwrap(); + d.block(block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect() +} + +const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b)); +const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c)); +const GRAY: Color = Color::Index(8); + +#[test] +fn exports_both_drawings_as_colored_groups_in_a_new_drawing() { + let mut s = compared(); + let before = s.docs.len(); + let source = s.doc().unwrap().clone(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(s.docs.len(), before + 1, "{r}"); + assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}"); + assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg"); + assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled"); + assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it"); + assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}"); + assert_eq!(r["truncated"], false); + let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap()); + assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED")); + // This drawing: the moved circle and the new arc in green, the unchanged line in gray. + assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]); + // The other drawing: the circle where it was and the erased text, in red, in its draw order. + assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]); + let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect(); + assert_eq!(order, ["Circle", "Text"]); + let circle_there = members(&s, other).iter().find_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center), + _ => None, + }); + assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0))); + // Model space holds the other drawing's objects first, then this drawing's. + let d = s.doc().unwrap(); + let order: Vec = d.model.iter().map(|e| e.handle).collect(); + let groups: Vec = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect(); + assert_eq!(order, groups); + // Every handle is new and unique. + let mut seen = std::collections::HashSet::new(); + for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) { + assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex()); + } + // The drawing COMPARE ran in is unchanged, and the comparison stays on it. + let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap(); + assert!(*s.docs[back].doc == source); + assert!(s.compare.is_some()); +} + +#[test] +fn takes_the_other_drawings_layers_and_blocks_it_needs() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + // The other drawing holds a reference to a block this one lacks, on a layer this one lacks. + let mut other = s.doc().unwrap().clone(); + other.handseed = 0x9000; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT")))); + let mut bolt = Block::new("BOLT"); + bolt.entities.push(Entity::new(Handle(0x8001), line(1.0))); + other.blocks.insert("PART".into(), Arc::new(part)); + other.blocks.insert("BOLT".into(), Arc::new(bolt)); + other.layers.push(Layer::new("Parts")); + other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap(); + // A group of this drawing's name does not carry over, so the snapshot takes the first name. + s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["current"], "COMPARE_CURRENT", "{r}"); + let d = s.doc().unwrap(); + assert_eq!(d.groups.len(), 2); + assert!(d.layer("Parts").is_some()); + assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied"); + assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies"); + assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000)); + let refs: Vec = members(&s, "COMPARE_COMPARED") + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)), + _ => None, + }) + .collect(); + assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]); +} + +#[test] +fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() { + let mut s = Session::new(); + let mut mine = Block::new("*U1"); + mine.entities.push(Entity::new(Handle(0x700), line(1.0))); + s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap(); + // The other drawing uses the same anonymous name for other geometry. + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + let mut theirs = Block::new("*U1"); + theirs.entities.push(Entity::new(Handle(0x8000), line(5.0))); + other.blocks.insert("*U1".into(), Arc::new(theirs)); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept"); + let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect(); + // The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy. + assert_eq!(ends(&s, "*U2"), vec![5.0]); + assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}"); + assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]); + assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]); +} + +#[test] +fn block_contents_take_the_comparison_color() { + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) }); + part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = s.doc().unwrap().clone(); + other.model = Default::default(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let objs = members(&s, r["current"].as_str().unwrap()); + let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") }; + assert_eq!(i.block, "COMPARE_CURRENT_PART"); + let d = s.doc().unwrap(); + let copy = d.block("COMPARE_CURRENT_PART").unwrap(); + assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too"); + // The block that refers to itself refers to its own copy, which ends the copying. + assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART"))); + // This drawing's definition is left as it was. + assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1)); +} + +#[test] +fn colors_carried_apart_from_the_object_color_are_replaced() { + let mut s = Session::new(); + s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap(); + s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap(); + s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap(); + s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap(); + let d = s.doc_mut().unwrap(); + d.dim_styles[0].dim_line_color = Color::Index(1); + d.dim_styles[0].text_color = Color::Index(5); + let mut data = d.table_styles[0].cell_style(CellStyle::DATA); + (data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3)); + d.table_styles[0].cell_styles.push(data); + let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle; + d.modify_entity(mline, |e| { + if let EntityKind::MLine(m) = &mut e.kind { + m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3)); + m.def.fill = Some(Color::Index(4)); + } + }) + .unwrap(); + let other = Drawing::new_imperial(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let mut kinds = Vec::new(); + let d = s.doc().unwrap(); + for e in members(&s, r["current"].as_str().unwrap()) { + match &e.kind { + EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"), + EntityKind::Dimension(dm) => { + let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides); + assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock)); + } + EntityKind::Table(t) => { + assert_eq!(t.style, "COMPARE_CURRENT_Standard"); + assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x")); + let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap(); + let data = st.cell_style(CellStyle::DATA); + assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock)); + } + EntityKind::Tolerance(t) => { + assert_eq!(t.style, "COMPARE_CURRENT_Standard"); + let st = d.dim_style(&t.style).unwrap(); + assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock)); + } + EntityKind::MLine(m) => { + assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock)); + assert_eq!(m.def.fill, None); + } + _ => continue, + } + kinds.push(e.kind.type_name()); + } + assert_eq!(kinds.len(), 5, "{kinds:?}"); + // This drawing's styles are left as they were. + assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1)); + assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1)); +} + +#[test] +fn edits_after_compare_count_and_objects_keep_their_layers() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let copy: Drawing = s.doc().unwrap().clone(); + s.open_drawing(copy, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + // A line drawn after COMPARE ran is only in this drawing. + s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let mut got = contents(&s, r["current"].as_str().unwrap()); + got.sort_by_key(|(_, c)| *c == GRAY); + assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}"); + // The copies sit in model space on their own layer, not inside a block. + let d = s.doc().unwrap(); + assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_)))); +} + +#[test] +fn copies_drop_associations_to_the_source_objects() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let l = handle_of(&s, "Line"); + s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap(); + let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None }); + assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line"); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(assoc(s.doc().unwrap()), Some(0)); +} + +#[test] +fn relative_xref_paths_become_paths_from_the_source_folder() { + let folder = std::env::temp_dir().join("cadcraft-compareexport"); + let host = folder.join("host.dxf"); + let mut d = Drawing::new_imperial(); + let mut x = Block::new("X"); + x.xref_path = Some("parts/x.dwg".into()); + d.blocks.insert("X".into(), Arc::new(x)); + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap(); + let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() }; + d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap(); + let mut b = Block::new("PIC"); + b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image))); + d.blocks.insert("PIC".into(), Arc::new(b)); + let mut s = Session::new(); + s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string())); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let got = d.block("X").unwrap().xref_path.clone().unwrap(); + assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg")); + // Images in model space and in blocks too. + let want = folder.join("img").join("a.png"); + let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }); + let images: Vec = images.collect(); + assert_eq!(images.len(), 2); + assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}"); +} + +#[test] +fn relative_xref_paths_of_a_compared_file_take_that_files_folder() { + // Test file format: any bytes read as a drawing with a reference to xref Y at `y.dwg`. + fn read(_bytes: &[u8], _name: &str) -> Result { + let mut d = Drawing::new_imperial(); + let mut y = Block::new("Y"); + y.xref_path = Some("y.dwg".into()); + d.blocks.insert("Y".into(), Arc::new(y)); + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("Y"))).map_err(|e| e.to_string())?; + Ok(d) + } + cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("other.dxf"); + std::fs::write(&path, "x").unwrap(); + // Named by its absolute path and by a path relative to the working directory. + let mut names = vec![path.to_string_lossy().to_string()]; + names.extend(relative_to_cwd(&path)); + for name in names { + let mut s = Session::new(); + s.execute("compare", &json!({"path": name})).unwrap(); + // The compared file's xref is only there; the copy keeps the file's folder. + s.execute("compareexport", &json!({})).unwrap(); + let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap(); + let got = std::path::Path::new(&got); + assert!(got.is_absolute(), "{got:?}, compared as {name}"); + assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg"))); + assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}"); + } + std::fs::remove_dir_all(&dir).ok(); +} + +/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when +/// the two share a root. +fn relative_to_cwd(p: &std::path::Path) -> Option { + use std::path::Component; + let cwd = std::env::current_dir().ok()?; + let (a, b): (Vec, Vec) = (cwd.components().collect(), p.components().collect()); + let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + if shared == 0 { + return None; + } + let mut rel = std::path::PathBuf::new(); + a[shared..].iter().for_each(|_| rel.push("..")); + b[shared..].iter().for_each(|c| rel.push(c)); + Some(rel.to_string_lossy().to_string()) +} + +#[test] +fn says_when_blocks_of_the_other_drawing_were_left_out() { + let mut other = Drawing::new_imperial(); + for i in 0..10_001 { + let name = format!("B{i}"); + let mut b = Block::new(&name); + b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0))); + other.blocks.insert(name.clone(), Arc::new(b)); + other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap(); + } + other.handseed = other.handseed.max(0x20_0000); + let mut s = Session::new(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["truncated"], true, "{}", r["message"]); + assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); + // The same blocks in this drawing, compared with an empty one: past the limit of recolored + // copies some references keep their blocks' own colors. + let ours = s.docs[1].doc.as_ref().clone(); + let mut s = Session::new(); + s.open_drawing(ours, "Ours", None); + s.execute("compare", &json!({"document": 0})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(r["truncated"], true, "{}", r["message"]); + assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]); +} + +#[test] +fn needs_a_comparison_of_the_current_drawing() { + let mut s = Session::new(); + assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet"); + let mut s = compared(); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing"); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compareclose", &json!({})).unwrap(); + assert!(s.execute("compareexport", &json!({})).is_err(), "closed"); + // Typed at the command line it runs too. + let mut s = compared(); + let before = s.docs.len(); + s.cmdline("compareexport").unwrap(); + assert_eq!(s.docs.len(), before + 1); +} diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index aae585caa..0f1ef4bba 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -85,6 +85,9 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf if ui.button("▶").on_hover_text(crate::tl!("Next Change")).clicked() { action = Some("compare.next"); } + if ui.button(crate::tl!("Export")).on_hover_text(crate::tl!("Export Comparison")).clicked() { + action = Some("compareexport"); + } if ui.button("×").on_hover_text(crate::tl!("Close Comparison")).clicked() { action = Some("compareclose"); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..563147172 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -150,6 +150,7 @@ Close All Other Drawings إغلاق كل الرسومات الأخرى Close Array Editing إغلاق تحرير المصفوفة Close Comparison إغلاق المقارنة + Export Comparison تصدير المقارنة Closed مغلق Coincident تطابق Collapse Palettes طي اللوحات diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 139dd9d9e..d354cdc18 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -141,6 +141,7 @@ Close All Other Drawings Zavřít všechny ostatní výkresy Closed Uzavřeno Close Comparison Zavřít porovnání + Export Comparison Exportovat porovnání Coincident Totožnost Collapse Tool Sets Sbalit sady nástrojů Collinear Kolinearita diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1ec3f82fb..6d9132a57 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -165,6 +165,7 @@ Close Array Editing Κλείσιμο επεξεργασίας διάταξης Close Block Editor Κλείσιμο επεξεργαστή μπλοκ Close Comparison Κλείσιμο σύγκρισης + Export Comparison Εξαγωγή σύγκρισης Closed Κλειστό Coincident Σύμπτωση (Coincident) Collapse Palettes Σύμπτυξη παλετών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9685dee94..6f40e33fe 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -140,6 +140,7 @@ Close All Other Drawings Cerrar todos los demás dibujos Close Array Editing Cerrar la edición de matriz Close Comparison Cerrar comparación + Export Comparison Exportar comparación Closed Cerrada Coincident Coincidente Collapse Palettes Contraer paletas diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3095ea9af..44e2cfbb0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -178,6 +178,7 @@ Close Array Editing Kiosztás szerkesztésének lezárása Close Block Editor Blokkszerkesztő bezárása Close Comparison Összehasonlítás bezárása + Export Comparison Összehasonlítás exportálása Closed Zárt Coincident Egybeeső Collapse Palettes Paletták összecsukása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb3a5388e..caad02ff2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -147,6 +147,7 @@ menu Modify Edita Close All Other Drawings Chiudi tutti gli altri disegni Closed Chiusa Close Comparison Chiudi confronto + Export Comparison Esporta confronto Coincident Coincidente Collapse Palettes Comprimi tavolozze Collapse Tool Sets Comprimi set di strumenti diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8b7b016fe..248d9a2b7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -141,6 +141,7 @@ Close All Other Drawings 다른 도면 모두 닫기 Closed 닫힘 Close Comparison 비교 닫기 + Export Comparison 비교 내보내기 Coincident 일치 Collapse Palettes 팔레트 접기 Collapse Tool Sets 도구 세트 접기 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 473b38fb8..5ea047d7f 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -139,6 +139,7 @@ Close All Other Drawings Fechar todos os outros desenhos Close Array Editing Fechar edição de matriz Close Comparison Fechar comparação + Export Comparison Exportar comparação Closed Fechada Coincident Coincidente Collapse Palettes Recolher paletas diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 176f1bb38..b7720902b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -143,6 +143,7 @@ Close All Other Drawings Stäng alla andra ritningar Close Array Editing Avsluta redigering av array Close Comparison Stäng jämförelse + Export Comparison Exportera jämförelse Closed Sluten Coincident Sammanfallande Collapse Palettes Fäll ihop paletter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f1240778c..d63e5c8b9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -179,6 +179,7 @@ Close Array Editing Dizi Düzenlemeyi Kapat Close Block Editor Blok Düzenleyiciyi Kapat Close Comparison Karşılaştırmayı Kapat + Export Comparison Karşılaştırmayı Dışa Aktar Closed Kapalı Coincident Çakışık Collapse Palettes Paletleri Daralt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb902c57c..a35ff341f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -144,6 +144,7 @@ Close All Other Drawings Закрити всі інші креслення Closed Замкнений Close Comparison Закрити порівняння + Export Comparison Експортувати порівняння Coincident Збіг Collapse Palettes Згорнути палітри Collapse Tool Sets Згорнути набори інструментів diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index c3aa9f95d..748184131 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1161,6 +1161,7 @@ Over-constrained 过度约束 Previous Change 上一个更改 Close Comparison 关闭比较 + Export Comparison 导出比较 Plotter Manager 打印机管理 Clear Command History 清除命令历史记录 Degrees of freedom: {n} 自由度: {n} diff --git a/docs/gaps.md b/docs/gaps.md index 44ed6f22e..0c5613015 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT exports a comparison to a new drawing: F22 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), and remove or update a row in the same PR @@ -43,7 +43,7 @@ do them first. | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | -| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once, and has no COMPAREEXPORT report; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | +| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .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-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e529cda9..6b5407d60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (RECTANG centre construction mode, #1127; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -156,7 +156,7 @@ presence: an option that prints "not available yet" doesn't count. | Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) | | Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) | | Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) | -| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563), MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | +| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | | Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) | | 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) | | Collaboration and cloud (Sheet Set Manager, Share, Traces, Markup Import, Activity Insights, DWG Compare) | 2% | 5% | 40–70 | none of these exist | [gaps](gaps.md#features) | @@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured | | 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured | | 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured | From 39e9b628295ca496f56dec395161d825dc75c6c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:22:32 -0700 Subject: [PATCH 04/91] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5c5d61005..40d6ba823 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -87,3 +87,7 @@ real_name = "Richard Sauer" display_name = "Richard Sauer" sync_github_name = false +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + From 028b98d71cd2b396516d9b95983603e56cb726f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:29:26 -0700 Subject: [PATCH 05/91] CUTBASE: cut objects to the clipboard with a base point Edit > Cut with Base Point asks for a base point, then for the objects, copies them to the clipboard with that base point and erases them, as COPYBASE does without the erase. A pickfirst selection skips the object prompt. Objects on locked layers stay, as with CUTCLIP, which now shares the cut helper. The JSON form takes {handles?, base: [x,y]}. Catalog rows for the new menu label are added in all 15 languages, and docs/parity-checklist.md is regenerated with cargo xtask parity. --- crates/engine/src/cmd/edit.rs | 37 +++++++++++++++---- crates/engine/tests/clipboard.rs | 60 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + docs/parity-checklist.md | 15 +++----- 18 files changed, 109 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 822ef8760..813a1c856 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,6 +26,10 @@ pub fn specs() -> Vec { .key("Cmd+X") .params("{handles?}") .interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))), + CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase) + .menu(&["Edit", "Cut with Base Point"]) + .params("{handles?, base: [x,y]}") + .interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))), CommandSpec::new("copyclip", "Copy", run_copyclip) .menu(&["Edit", "Copy"]) .key("Cmd+C") @@ -457,15 +461,28 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { Ok(json!({ "copied": n })) } -fn run_cut(s: &mut Session, p: &Value) -> Result { - let mut hs = targets(s, p)?; - hs.retain(|h| !super::curves::is_locked(s, *h)); - let n = copy_to_clip(s, &hs, None)?; +/// Copy the objects that are not on locked layers to the clipboard and erase them. +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { + let hs: Vec = hs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); + let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); + Ok(n) +} + +fn run_cut(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = cut_to_clip(s, &hs, None)?; + Ok(json!({ "cut": n })) +} + +fn run_cutbase(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_param(p, "base"); + let n = cut_to_clip(s, &hs, base)?; Ok(json!({ "cut": n })) } @@ -649,23 +666,29 @@ impl Interactive for SelectM { } } -/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection). +/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst +/// selection). CUTBASE also erases the objects it copies. #[derive(Default)] struct CopyBaseM { base: Option, sel: SelectPhase, + cut: bool, } impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { - copy_to_clip(s, hs, self.base)?; + if self.cut { + cut_to_clip(s, hs, self.base)?; + } else { + copy_to_clip(s, hs, self.base)?; + } Ok(Step::Done) } } impl Interactive for CopyBaseM { fn name(&self) -> &'static str { - "COPYBASE" + if self.cut { "CUTBASE" } else { "COPYBASE" } } fn begin(&mut self, s: &mut Session) -> Result { self.sel = SelectPhase::begin(s); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 03ec40842..606f2d190 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -170,6 +170,66 @@ fn pasteblock_makes_a_block_of_the_clipboard() { assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some()); } +#[test] +fn cutbase_cuts_with_the_given_base_point() { + let mut s = Session::empty(); + let mut da = drawing_a(); + if let Some(l) = da.layer_mut("WALLS") { + l.locked = true; + } + let a = s.open_drawing(da, "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); + // The wall is on a locked layer, so only the door is cut. + assert_eq!(r, json!({ "cut": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); + let left = model(&s); + assert_eq!(left.len(), 1); + assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays"); + + // PASTECLIP places the base point at the insertion point. + s.active = b; + s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap(); + let pasted = model(&s); + assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0)); + + // Undo in A brings the door back. + s.active = a; + s.execute("u", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 2); +} + +#[test] +fn cutbase_asks_for_the_base_point_then_the_objects() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + let undo_before = s.state().unwrap().undo.len(); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0)); + assert_eq!(model(&s).iter().map(|e| e.handle).collect::>(), [hs[0]]); + assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step"); + + // With a pickfirst selection, the base point is the only prompt. + s.set_selection(vec![hs[0]]); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().model.is_empty()); + assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0)); + assert!(s.selection().is_empty()); +} + /// A stand-in for the DXF writer and reader: "writes" the drawing to memory. static WRITTEN: Mutex> = Mutex::new(None); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..58ce93144 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -203,6 +203,7 @@ Current selection التحديد الحالي Current: {color} الحالي: {color} Cut قص + Cut with Base Point قص من نقطة أساس Cyan سماوي Cylinder أسطوانة DWG DWG diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 139dd9d9e..073a3e344 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -186,6 +186,7 @@ Current selection Aktuální výběr Current: {color} Aktuální: {color} Cut Vyjmout + Cut with Base Point Vyjmout se základním bodem Cyan Azurová Cylinder Válec DWG DWG diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e2e92d4c8..d7feda3bb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -145,6 +145,7 @@ Current selection Aktuelle Auswahl Current: {color} Aktuell: {color} Cut Ausschneiden + Cut with Base Point Mit Basispunkt ausschneiden Cyan Cyan Cylinder Zylinder DWG DWG diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1ec3f82fb..2471b4314 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -227,6 +227,7 @@ Curved Καμπύλο Custom Προσαρμοσμένο Cut Αποκοπή + Cut with Base Point Αποκοπή με σημείο βάσης Cyan Κυανό Cylinder Κύλινδρος Dark Σκούρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9685dee94..7124bd2e0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -192,6 +192,7 @@ Current selection Selección actual Current: {color} Actual: {color} Cut Cortar + Cut with Base Point Cortar con punto base Cyan Cian Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d8b100711..ceed3305e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -144,6 +144,7 @@ Current selection Sélection actuelle Current: {color} Actuel : {color} Cut Couper + Cut with Base Point Couper avec point de base Cyan Cyan Cylinder Cylindre DWG DWG diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3095ea9af..92bace42a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -239,6 +239,7 @@ Curved Ívelt Custom Egyéni Cut Kivágás + Cut with Base Point Kivágás bázisponttal Cyan Cián Cylinder Henger DWG DWG diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb3a5388e..b513eca74 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -194,6 +194,7 @@ menu Modify Edita Current selection Selezione corrente Current: {color} Corrente: {color} Cut Taglia + Cut with Base Point Taglia con punto base Cyan Ciano Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8b7b016fe..44a16e0ff 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -187,6 +187,7 @@ Current selection 현재 선택 항목 Current: {color} 현재: {color} Cut 잘라내기 + Cut with Base Point 기준점을 지정하여 잘라내기 Cyan 청록색 Cylinder 원기둥 DWG DWG diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 473b38fb8..3d8c0bd7f 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -191,6 +191,7 @@ Current selection Seleção atual Current: {color} Atual: {color} Cut Recortar + Cut with Base Point Recortar com ponto base Cyan Ciano Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c7a6baaf2..a76b06f45 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -144,6 +144,7 @@ Current selection Текущее выделение Current: {color} Текущий: {color} Cut Вырезать + Cut with Base Point Вырезать с базовой точкой Cyan Голубой Cylinder Цилиндр DWG DWG diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 176f1bb38..20d2ab601 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -195,6 +195,7 @@ Current selection Aktuell markering Current: {color} Aktuell: {color} Cut Klipp ut + Cut with Base Point Klipp ut med baspunkt Cyan Cyan Cylinder Cylinder DWG DWG diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f1240778c..a98ac82c5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -240,6 +240,7 @@ Curved Eğri Custom Özel Cut Kes + Cut with Base Point Taban Noktasıyla Kes Cyan Camgöbeği Cylinder Silindir DWG DWG diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb902c57c..eaefc206e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -191,6 +191,7 @@ Current selection Поточний вибір Current: {color} Поточний: {color} Cut Вирізати + Cut with Base Point Вирізати з базовою точкою Cyan Блакитний Cylinder Циліндр DWG DWG diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index c3aa9f95d..b85c5ffa2 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -145,6 +145,7 @@ Current selection 当前选择 Current: {color} 当前: {color} Cut 剪切 + Cut with Base Point 带基点剪切 Cyan 青色 Cylinder 圆柱体 DWG DWG diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..d64d0308f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,20 +1,19 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | -| Edit | 13 | 11 | 85% | +| Edit | 13 | 12 | 92% | | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 64 | 75% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -33,7 +32,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - File > Markup Import... - File > Share... - Edit > Undo Group of commands -- Edit > Cut with Base Point - View > Pan > Point - View > Orbit > Constrained Orbit - View > Orbit > Free Orbit @@ -101,13 +99,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... From 6fe715e11c62156367a03ea16e1a6717d00fdaa0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:14:34 -0700 Subject: [PATCH 06/91] THICKNESS: Format > Thickness, and new 2D objects take the current thickness New lines, arcs, circles, points and lightweight polylines (for example PLINE, POLYGON, DONUT and BOUNDARY) now take the THICKNESS system variable as their 3D thickness. THICKNESS could already be set with SETVAR and with the Properties palette when nothing is selected, but new objects were always created with a thickness of 0, apart from RECTANG's own Thickness option. RECTANG keeps that option, and single-line text does not take THICKNESS, as in AutoCAD. The new THICKNESS command is on Format > Thickness. Typed or picked from the menu, it asks "Enter new value for THICKNESS <0.0000>:"; Enter keeps the current value, and a negative value is accepted. docs/parity-checklist.md is regenerated. --- crates/engine/src/cmd/draw2.rs | 11 +++---- crates/engine/src/cmd/props.rs | 41 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 13 +++++++++ crates/engine/src/tests.rs | 52 ++++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 15 +++------- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 68dfd5dbc..527f66624 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1631,10 +1631,9 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a }; let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); let hd = s.add_entity(k)?; + // A rectangle has its own thickness option and does not take THICKNESS. let th = f64_or(p, "thickness", 0.0); - if th != 0.0 { - s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; - } + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?; added(hd) } @@ -1683,10 +1682,8 @@ impl RectM2 { } let first = self.origin(w, h).unwrap_or(a); let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; - if self.thickness != 0.0 { - let t = self.thickness; - s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; - } + let t = self.thickness; + s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; Ok(Step::Done) } /// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 7ef3aa687..75dc0b046 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -32,6 +32,11 @@ pub fn specs() -> Vec { CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))), CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), + CommandSpec::new("thickness", "Thickness", run_thickness) + .menu(&["Format", "Thickness"]) + .params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}") + .transparent() + .interactive(|_| Ok(Box::new(ThicknessM))), CommandSpec::new("limits", "Drawing Limits", run_limits).menu(&["Format", "Drawing Limits"]).params("{min: [x,y], max: [x,y]}"), CommandSpec::new("style", "Text Style...", run_style) .menu(&["Format", "Text Style..."]) @@ -534,6 +539,42 @@ impl Interactive for LtscaleM { } } +fn run_thickness(s: &mut Session, p: &Value) -> Result { + let t = f64_req("thickness", p, "thickness")?; + if !t.is_finite() || t.abs() > 1e12 { + return Err(bad("thickness", "thickness is out of range")); + } + s.doc_mut()?.header.set_f64("THICKNESS", t); + Ok(json!({ "thickness": t })) +} + +/// Format > Thickness and typed THICKNESS: "Enter new value for THICKNESS <0.0000>:". +struct ThicknessM; + +impl Interactive for ThicknessM { + fn name(&self) -> &'static str { + "THICKNESS" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| d.header.f64("THICKNESS", 0.0)).unwrap_or(0.0); + Prompt::new("Enter new value for THICKNESS", Accept::NUMBER).default(format!("{cur:.4}")) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match i { + Input::Enter | Input::Cancel => return Ok(Step::Done), + Input::Text(t) => t, + _ => return Ok(Step::Continue), + }; + match super::machines::number(&text).map(|t| run_thickness(s, &json!({ "thickness": t }))) { + Some(Ok(_)) => Ok(Step::Done), + _ => { + s.echo("Requires a number."); + Ok(Step::Continue) + } + } + } +} + fn run_units(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 117db5813..e37aadbdd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -885,6 +885,19 @@ impl Session { if d.annotative_by_style(&kind) { common.anno_scales = vec![d.current_anno_scale()]; } + // Lines, arcs, circles, points, polylines and 2D solids take the current 3D thickness + // (THICKNESS). RECTANG sets its own, and text never takes it. + if matches!( + kind, + EntityKind::Line(_) + | EntityKind::Arc(_) + | EntityKind::Circle(_) + | EntityKind::Point(_) + | EntityKind::LwPolyline(_) + | EntityKind::Solid(_) + ) { + common.thickness = Some(d.header.f64("THICKNESS", 0.0)).filter(|t| t.is_finite()).unwrap_or(0.0); + } let h = self.doc_mut()?.add(&space, common, kind)?; Ok(h) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6c4c41d2..731256107 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1309,6 +1309,58 @@ fn typed_ltscale_asks_for_the_scale() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 0.1); } +#[test] +fn format_thickness_asks_for_the_thickness() { + let mut s = Session::new(); + s.start("thickness").unwrap(); + assert!(s.prompt_text().contains("Enter new value for THICKNESS <0.0000>"), "{}", s.prompt_text()); + // After text that is not a number, the prompt asks again. + s.cmdline("thick").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + // A negative thickness is accepted. + s.cmdline("-1.5").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5); + // Typed, Enter keeps the current thickness. + s.cmdline("thickness").unwrap(); + assert!(s.prompt_text().contains("<-1.5000>"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), -1.5); + assert!(s.execute("thickness", &json!({"thickness": "thick"})).is_err()); + assert!(s.execute("thickness", &json!({"thickness": 1e13})).is_err()); +} + +#[test] +fn new_objects_take_the_current_thickness() { + let mut s = Session::new(); + s.execute("thickness", &json!({"thickness": 2.5})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap(); + s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap(); + s.execute("point", &json!({"at": [1, 1]})).unwrap(); + s.execute("pline", &json!({"vertices": [[0, 0], [5, 0], [5, 5]]})).unwrap(); + s.execute("polygon", &json!({"sides": 6, "center": [20, 20], "radius": 2})).unwrap(); + s.execute("donut", &json!({"center": [30, 30], "inside": 1, "outside": 2})).unwrap(); + let thick: Vec<(&str, f64)> = s.doc().unwrap().model.iter().map(|e| (e.kind.type_name(), e.common.thickness)).collect(); + assert_eq!(thick.len(), 7, "{thick:?}"); + assert!(thick.iter().all(|(_, t)| *t == 2.5), "{thick:?}"); + // RECTANG keeps its own thickness option, and text does not take THICKNESS. + s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4]})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "thickness": 1})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 1.0); + let before = s.doc().unwrap().model.len(); + s.cmdline("rectang 0,0 4,4").unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + s.execute("text", &json!({"at": [0, 0], "text": "A"})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); + // An ellipse has no thickness. + s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap(); + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 0.0); +} + fn setvar_prompt_open(s: &Session) -> bool { s.running.as_ref().is_some_and(|r| r.id == "setvar") } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..a25961050 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,11 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| @@ -13,8 +12,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com | Edit | 13 | 11 | 85% | | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | -| Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Format | 27 | 26 | 96% | +| Tools | 85 | 64 | 75% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -85,7 +84,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Insert > ACIS File... - Insert > Drawing Exchange Binary... - Format > Transparency -- Format > Thickness - Tools > Spelling - Tools > Inquiry > Volume - Tools > Update Fields @@ -101,13 +99,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... From 5a82f090df367c4dd8bc6f5dfa4af3d144b5e0d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:39:21 -0700 Subject: [PATCH 07/91] BLOCKREPLACE: Tools > Express Tools > Replace Block replaces every reference of one block with another BLOCKREPLACE (and -BLOCKREPLACE) turns every reference of one block, in model space, the layouts and other blocks, into a reference of another block at the same insertion point, scale and rotation. Each new reference carries the replacement's attributes, with the values of the tags both blocks define, and is annotative when the replacement is. The command then asks whether to purge the replaced definition, and removes it only when no dimension style names it and no block reference, dimension or multileader in the drawing or in another block definition does. The command refuses a replacement that references the block it replaces, a dynamic block as the block to replace, and blocks that come from an xref. It does not run while the Block Editor or REFEDIT is open. The ATTSYNC helpers in blocks/attribs.rs walk the references and build the new attributes. The self-reference check that BLOCK, INSERT of a file, REFCLOSE and Lisp block creation share now also follows dimension blocks and multileader block content, so BLOCK no longer accepts a redefinition of A from a multileader whose content is A. docs/parity-checklist.md is regenerated. --- crates/engine/src/cmd/blocks.rs | 15 +- crates/engine/src/cmd/blocks/attribs.rs | 30 ++- crates/engine/src/cmd/blocks/blockreplace.rs | 220 +++++++++++++++++++ crates/engine/src/cmd/blocks/purge.rs | 2 +- crates/engine/tests/it/block_redefine.rs | 6 + crates/engine/tests/it/blockreplace.rs | 186 ++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + docs/parity-checklist.md | 13 +- 23 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/blockreplace.rs create mode 100644 crates/engine/tests/it/blockreplace.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f95a99b93..2b7fc0ba7 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -74,6 +74,7 @@ pub fn specs() -> Vec { attribs::attdisp_spec(), attribs::attsync_spec(), burst::burst_spec(), + blockreplace::blockreplace_spec(), extract::dataextraction_spec(), extract::eattext_spec(), ] @@ -88,12 +89,19 @@ pub(crate) fn valid_block_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } -/// The block an entity references, when it is a block reference. +/// The block an entity draws, if any: a block reference's block, a dimension's block or a +/// multileader's block content. fn referenced_block(e: &Entity) -> Option { - if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None } + match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + EntityKind::Dimension(dm) => dm.block.clone(), + EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()), + _ => None, + } } -/// Whether any of `ents` refers to block `name`, directly or through nested block references. +/// Whether any of `ents` refers to block `name`, directly or through nested block references, +/// dimension blocks and multileader block content. pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { let mut todo: Vec = ents.iter().filter_map(referenced_block).collect(); let mut seen = std::collections::HashSet::new(); @@ -878,3 +886,4 @@ impl Interactive for AttdefM { mod attribs; mod battman; +mod blockreplace; diff --git a/crates/engine/src/cmd/blocks/attribs.rs b/crates/engine/src/cmd/blocks/attribs.rs index 38e1c5c89..7cb53dad5 100644 --- a/crates/engine/src/cmd/blocks/attribs.rs +++ b/crates/engine/src/cmd/blocks/attribs.rs @@ -84,7 +84,7 @@ impl Interactive for AttdispM { /// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and /// shown as defined, keeping the values of attributes whose tag (any case) is still defined. /// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD. -fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { +pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { let m = ins.transform(blk.base.xy()); defs.iter() .map(|ad| { @@ -119,12 +119,16 @@ fn references<'a>(ents: impl Iterator>, name: &str) -> Ve /// Apply `f` to every reference of block `name` (in model space, layouts and other blocks); /// returns how many references there were. fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize { - let mut n = 0; - let mut update = |e: &mut Entity| { + update_reference_entities(d, name, |e| { if let EntityKind::Insert(ins) = &mut e.kind { f(ins); } - }; + }) +} + +/// [`update_references`], with `f` given the whole object. +pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize { + let mut n = 0; let spaces: Vec = std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect(); for sp in spaces { @@ -169,18 +173,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R })) } -/// Synchronize every reference of block `name` (in model space, layouts and other blocks). -/// Returns the block's name as defined and how many references were updated. -pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { - let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; - let defs: Vec<&Attrib> = blk - .entities +/// The attribute definitions of `blk` that references carry (constant ones are not). +pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> { + blk.entities .iter() .filter_map(|e| match &e.kind { EntityKind::AttDef(a) if !a.constant => Some(a), _ => None, }) - .collect(); + .collect() +} + +/// Synchronize every reference of block `name` (in model space, layouts and other blocks). +/// Returns the block's name as defined and how many references were updated. +pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { + let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + let defs = attdefs(&blk); let d = s.doc_mut()?; let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins)); Ok((blk.name.clone(), n)) diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs new file mode 100644 index 000000000..c20012784 --- /dev/null +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -0,0 +1,220 @@ +//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space, +//! the layouts and other blocks, becomes a reference of another block at the same insertion +//! point, scale and rotation. The unreferenced block definition can then be purged. + +use cadcraft_doc::{Drawing, Entity, EntityKind}; +use serde_json::{Value, json}; + +use super::attribs::{attdefs, synced, update_reference_entities}; +use super::purge::{override_arrows, used_blocks}; +use super::references_block; +use crate::cmd::curves::KW; +use crate::cmd::machines::{SelOutcome, SelectPhase}; +use crate::cmd::{CommandSpec, bad, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn blockreplace_spec() -> CommandSpec { + CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace) + .menu(&["Tools", "Express Tools", "Replace Block..."]) + .alias(&["-blockreplace"]) + .params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}") + .interactive(|_| Ok(Box::new(BlockreplaceM::default()))) +} + +/// The defined name of block `name` (names are case-insensitive), when it is a named block of +/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved. +fn named_block(d: &Drawing, name: &str) -> Option { + d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone()) +} + +/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s +/// definition when nothing references it afterwards. Returns (references replaced, purged). +fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> { + let cmd = "blockreplace"; + if s.state()?.block_edit.is_some() { + return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first")); + } + let d = s.doc()?; + if d.refedit.is_some() { + return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first")); + } + if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) { + return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim()))); + } + let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?; + let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?; + if old.eq_ignore_ascii_case(&new) { + return Err(bad(cmd, "the replacement block must be a different block")); + } + // A dynamic block's references keep their property values in data that names the block. + if cadcraft_doc::dynblock::dynamic_block(d, &old).is_some() { + return Err(bad(cmd, format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))); + } + let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?; + // References of `old` inside `new` would make `new` reference itself. + if references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), &old) { + return Err(bad(cmd, format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))); + } + let defs = attdefs(&blk); + let scale = d.current_anno_scale(); + let d = s.doc_mut()?; + let n = update_reference_entities(d, &old, |e| { + if let EntityKind::Insert(ins) = &mut e.kind { + ins.block.clone_from(&blk.name); + ins.attribs = synced(&blk, &defs, ins); + } + // A reference of an annotative block is annotative, as INSERT makes it. + if !blk.annotative { + e.common.anno_scales.clear(); + } else if e.common.anno_scales.is_empty() { + e.common.anno_scales = vec![scale.clone()]; + } + }); + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned(); + let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some()); + Ok((n, purged)) +} + +/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions, +/// multileaders and block references of every other block definition, used or not. +fn referenced(d: &Drawing, name: &str) -> bool { + if used_blocks(d).contains(&name.to_ascii_uppercase()) { + return true; + } + let names = |e: &Entity| -> Vec { + match &e.kind { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(), + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + } + }; + d.blocks + .values() + .filter(|b| !b.name.eq_ignore_ascii_case(name)) + .flat_map(|b| b.entities.iter()) + .any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name))) +} + +fn run_blockreplace(s: &mut Session, p: &Value) -> Result { + let cmd = "blockreplace"; + let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?; + let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?; + let purge = match p.get("purge") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))), + }; + let (old, new) = (old.to_string(), new.to_string()); + let (n, purged) = replace(s, &old, &new, purge)?; + let d = s.doc()?; + let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string()); + Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged })) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Old, + New, + Select, + Purge, +} + +#[derive(Default)] +struct BlockreplaceM { + stage: Stage, + /// The stage a picked reference answers. + back: Stage, + sel: SelectPhase, + old: String, + new: String, +} + +impl BlockreplaceM { + /// Take `name` as the answer to the current name prompt. + fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> { + let d = s.doc()?; + let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?; + if stage == Stage::Old { + self.old = name; + self.stage = Stage::New; + } else { + if name.eq_ignore_ascii_case(&self.old) { + return Err(EngineError::Other("The replacement block must be a different block.".into())); + } + self.new = name; + self.stage = Stage::Purge; + } + Ok(()) + } + + fn finish(&mut self, s: &mut Session, purge: bool) -> Result { + let (n, purged) = replace(s, &self.old, &self.new, purge)?; + s.echo(format!( + "{n} reference{} of block {} replaced with {}.{}", + if n == 1 { "" } else { "s" }, + self.old, + self.new, + if purged { format!(" Block {} purged.", self.old) } else { String::new() } + )); + Ok(Step::Done) + } +} + +impl Interactive for BlockreplaceM { + fn name(&self) -> &'static str { + "BLOCKREPLACE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]), + Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]), + Stage::Select => Prompt::new("Select a block reference", Accept::SELECT), + Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + stage @ (Stage::Old | Stage::New) => match &i { + Input::Keyword(k) if k == "?" => { + let d = s.doc()?; + let mut names: Vec = d.blocks.keys().filter_map(|k| named_block(d, k)).collect(); + names.sort_by_key(|n| n.to_ascii_lowercase()); + s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) }); + } + Input::Keyword(k) if k == "Select" => { + self.back = stage; + self.sel = SelectPhase::single(); + self.stage = Stage::Select; + } + Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?, + Input::Enter | Input::Cancel => return Ok(Step::Done), + _ => {} + }, + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => {} + SelOutcome::Empty => self.stage = self.back, + SelOutcome::Done(hs) => { + s.set_selection(Vec::new()); + self.sel = SelectPhase::single(); + let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Insert(ins)) => Some(ins.block.clone()), + _ => None, + }); + match block { + Some(b) => self.take(s, self.back, &b)?, + None => return Err(EngineError::Other("That object is not a block reference.".into())), + } + } + }, + Stage::Purge => match &i { + Input::Keyword(k) if k == "No" => return self.finish(s, false), + Input::Keyword(_) | Input::Enter => return self.finish(s, true), + Input::Cancel => return Ok(Step::Done), + _ => return Err(EngineError::Other("Invalid option keyword.".into())), + }, + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 73c0ddc9b..68aea3c36 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator impl Iterator + '_ { +pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator + '_ { dm.overrides .iter() .filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock")) diff --git a/crates/engine/tests/it/block_redefine.rs b/crates/engine/tests/it/block_redefine.rs index e5a9cac16..136d2817d 100644 --- a/crates/engine/tests/it/block_redefine.rs +++ b/crates/engine/tests/it/block_redefine.rs @@ -40,6 +40,12 @@ fn a_block_cannot_be_redefined_to_reference_itself() { let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] })); assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); assert_eq!(contents(&s, "A"), ["line"]); + + // Through a multileader whose content is the block being redefined. + let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone(); + let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] })); + assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); + assert_eq!(contents(&s, "A"), ["line"]); } #[test] diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs new file mode 100644 index 000000000..eefb23f1f --- /dev/null +++ b/crates/engine/tests/it/blockreplace.rs @@ -0,0 +1,186 @@ +//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and +//! other blocks, becomes a reference of another at the same insertion point, scale and rotation. + +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +/// Make block `name` from what is in model space now (the objects are removed). +fn make_block(s: &mut Session, name: &str) { + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); +} + +/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a +/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated +/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A. +fn drawing() -> (Session, Handle) { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + make_block(&mut s, "A"); + s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap(); + s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + make_block(&mut s, "B"); + s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap(); + make_block(&mut s, "N"); + s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap(); + s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap(); + let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap(); + let ins = Insert { + block: "A".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap(); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) +} + +/// The block names of the references in `ents`. +fn refs<'a>(ents: impl Iterator>) -> Vec { + ents.filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect() +} + +fn insert(s: &Session, h: Handle) -> (Insert, String) { + let e = s.doc().unwrap().entity(h).unwrap(); + let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") }; + (i.clone(), e.common.layer.clone()) +} + +#[test] +fn blockreplace_replaces_every_reference_and_keeps_its_placement() { + let (mut s, h) = drawing(); + let before = insert(&s, h).0; + let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap(); + assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false})); + + let (after, layer) = insert(&s, h); + assert_eq!(after.block, "B"); + assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R")); + // The replacement's attributes, with the value of the tag both blocks define. + let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect(); + assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]); + + let d = s.doc().unwrap(); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]); + assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]); + assert!(d.block("A").is_some(), "the definition stays without `purge`"); + + s.execute("undo", &json!({})).unwrap(); + assert_eq!(insert(&s, h).0, before); +} + +#[test] +fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() { + let (mut s, h) = drawing(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap(); + let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len(); + assert_eq!(scales(&s), 0); + s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap(); + assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale"); + s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap(); + assert_eq!(scales(&s), 0); +} + +#[test] +fn blockreplace_purges_the_replaced_definition_on_request() { + let (mut s, _) = drawing(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!(r["purged"], json!(true)); + assert!(s.doc().unwrap().block("A").is_none()); +} + +#[test] +fn blockreplace_keeps_a_definition_an_unused_block_still_names() { + let (mut s, _) = drawing(); + // Block U, never inserted, holds a multileader whose content is block A. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false))); + assert!(s.doc().unwrap().block("A").is_some()); +} + +#[test] +fn blockreplace_refuses_what_it_cannot_replace() { + let (mut s, _) = drawing(); + let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block")); + assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found")); + assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found")); + // N holds a reference of A, so N replacing A would reference itself. + assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references")); + // M holds a multileader whose content is N, so M reaches A too. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references")); + assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`")); + assert!(err(&mut s, json!({"with": "B"})).contains("`block`")); + // A block an xref brings in is not saved with the drawing. + s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A"))); + assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref")); + assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref")); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + // The Block Editor shows the block, not the drawing's references. + s.execute("bedit", &json!({"name": "B"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor")); +} + +#[test] +fn blockreplace_asks_for_both_blocks_and_whether_to_purge() { + let (mut s, h) = drawing(); + s.cmdline("-blockreplace").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log); + s.cmdline("a").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text()); + // A cannot replace itself: the prompt asks again. + s.cmdline("A").unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert!(s.running.is_none()); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + + // The block to replace picked from one of its references; Enter purges it. + s.cmdline("blockreplace").unwrap(); + s.cmdline("Select").unwrap(); + s.input(Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] "), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log); + let d = s.doc().unwrap(); + assert!(d.block("A").is_none()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(insert(&s, h).0.block, "B"); +} + +#[test] +fn blockreplace_no_keeps_the_replaced_definition() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + s.cmdline("B").unwrap(); + s.cmdline("No").unwrap(); + assert!(s.running.is_none()); + let d = s.doc().unwrap(); + assert!(d.block("A").is_some()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 3edc0db95..7649e68fc 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -12,6 +12,7 @@ mod attrib_props; mod attribute_tools; mod block_keep; mod block_redefine; +mod blockreplace; mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..e75ed6370 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -785,6 +785,7 @@ Rename Text Style إعادة تسمية نمط النص Rename... إعادة تسمية... Repeat {command} تكرار {command} + Replace Block... استبدال الكتلة... Requires a point (x,y, @dx,dy or @d **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| @@ -14,7 +13,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 65 | 76% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -101,14 +100,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text -- Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... - Tools > Express Tools > Super Hatch > Select Existing Object From 1fcdfc87b4116413be793bfcf5b9f62e2c917b74 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:53:25 -0700 Subject: [PATCH 08/91] ATTDISP: View > Attribute Display > Normal, On and Off, and 'ATTDISP inside another command ATTDISP had no menu entry. View > Attribute Display now has Normal, On and Off, which set ATTMODE to 1, 2 and 0 as ATTDISP's options do. ATTDISP can also be typed as 'ATTDISP while another command runs, and that command resumes afterward. The eight catalogs without a "Normal" row get one, and docs/parity-checklist.md is regenerated with cargo xtask parity. --- crates/engine/src/cmd/blocks.rs | 1 + crates/engine/src/cmd/blocks/attribs.rs | 16 ++++++++++++ crates/engine/tests/it/attdisp_attsync.rs | 32 +++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + docs/parity-checklist.md | 19 +++----------- 12 files changed, 61 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f95a99b93..cf05343d0 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -78,6 +78,7 @@ pub fn specs() -> Vec { extract::eattext_spec(), ] .into_iter() + .chain(attribs::attdisp_menu_specs()) .chain(xref::specs()) .chain(refedit::specs()) .chain(bedit::specs()) diff --git a/crates/engine/src/cmd/blocks/attribs.rs b/crates/engine/src/cmd/blocks/attribs.rs index 38e1c5c89..300ad29b7 100644 --- a/crates/engine/src/cmd/blocks/attribs.rs +++ b/crates/engine/src/cmd/blocks/attribs.rs @@ -16,6 +16,22 @@ pub(super) fn attdisp_spec() -> CommandSpec { CommandSpec::new("attdisp", "Attribute Display", run_attdisp) .params("{mode: \"normal\" | \"on\" | \"off\"} (sets ATTMODE to 1 / 2 / 0) → {attmode}") .interactive(|_| Ok(Box::new(AttdispM))) + .transparent() +} + +/// Returns the View > Attribute Display > Normal, On and Off commands, which set ATTMODE as the matching ATTDISP option does. +pub(super) fn attdisp_menu_specs() -> Vec { + vec![ + CommandSpec::new("attdisp.normal", "Normal", |s, _| set_attmode(s, 1)) + .menu(&["View", "Attribute Display", "Normal"]) + .params("{} (ATTDISP Normal: ATTMODE 1) → {attmode}"), + CommandSpec::new("attdisp.on", "On", |s, _| set_attmode(s, 2)) + .menu(&["View", "Attribute Display", "On"]) + .params("{} (ATTDISP ON: ATTMODE 2) → {attmode}"), + CommandSpec::new("attdisp.off", "Off", |s, _| set_attmode(s, 0)) + .menu(&["View", "Attribute Display", "Off"]) + .params("{} (ATTDISP OFF: ATTMODE 0) → {attmode}"), + ] } pub(super) fn attsync_spec() -> CommandSpec { diff --git a/crates/engine/tests/it/attdisp_attsync.rs b/crates/engine/tests/it/attdisp_attsync.rs index bbdbd2152..e079c00ca 100644 --- a/crates/engine/tests/it/attdisp_attsync.rs +++ b/crates/engine/tests/it/attdisp_attsync.rs @@ -59,6 +59,38 @@ fn attdisp_sets_attmode_and_the_display_follows_it() { assert!(s.execute("attdisp", &json!({"mode": "sometimes"})).is_err()); } +#[test] +fn view_attribute_display_menu_sets_attmode() { + let mut s = Session::new(); + for (id, item, mode) in [("attdisp.on", "On", 2), ("attdisp.off", "Off", 0), ("attdisp.normal", "Normal", 1)] { + let spec = cadcraft_engine::find_command(id).unwrap_or_else(|| panic!("no command {id}")); + assert_eq!(spec.menu, ["View", "Attribute Display", item]); + s.start(id).unwrap(); + assert!(s.running.is_none(), "{id}: {}", s.prompt_text()); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), mode, "{id}"); + } + assert_eq!(s.execute("attdisp.off", &json!({})).unwrap(), json!({"attmode": 0})); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 1); +} + +#[test] +fn attdisp_runs_transparently_inside_another_command() { + let mut s = Session::new(); + s.cmdline("LINE 0,0").unwrap(); + s.cmdline("'ATTDISP").unwrap(); + assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("attdisp")); + assert!(s.prompt_text().starts_with(">>"), "{}", s.prompt_text()); + s.cmdline("off").unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ATTMODE", 1), 0); + // LINE prompts again for its next point. + assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("line"), "{}", s.prompt_text()); + s.cmdline("10,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.doc().unwrap().model.iter().count(), 1); +} + #[test] fn attsync_brings_references_in_line_with_the_definitions() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..99af3cf1e 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -614,6 +614,7 @@ No supported 3D mouse found لم يُعثر على فأرة ثلاثية الأبعاد مدعومة Node عقدة None لا شيء + Normal عادي Not Found غير موجود Not available غير متاح Not available yet غير متاح بعد diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 139dd9d9e..e1be1e18e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -558,6 +558,7 @@ No selection Žádný výběr Node Bod None Žádné + Normal Normální Not available Není k dispozici Not available yet Zatím není k dispozici Numbered list Číslovaný seznam diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e2e92d4c8..2063191b3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -476,6 +476,7 @@ No selection Keine Auswahl Node Knoten None Keine + Normal Normal Not available Nicht verfügbar Not available yet Noch nicht verfügbar OK OK diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9685dee94..ff148179c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -589,6 +589,7 @@ No selection Sin selección Node Nodo None Ninguno + Normal Normal Not Found No encontrada Not available No disponible Not available yet Aún no disponible diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d8b100711..972902eeb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -471,6 +471,7 @@ No selection Aucune sélection Node Nœud None Aucun + Normal Normal Not available Non disponible Not available yet Pas encore disponible OK OK diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8b7b016fe..dee82c98c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -560,6 +560,7 @@ No selection 선택 항목 없음 Node 노드 None 없음 + Normal 보통 Not available 사용할 수 없음 Not available yet 아직 사용할 수 없음 Numbered list 번호 매기기 목록 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 473b38fb8..7ce6bb789 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -587,6 +587,7 @@ No selection Nenhuma seleção Node Nó None Nenhum + Normal Normal Not Found Não encontrado Not available Não disponível Not available yet Ainda não disponível diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c7a6baaf2..4cb8564eb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -471,6 +471,7 @@ No selection Ничего не выделено Node Узел None Нет + Normal Обычный Not available Недоступно Not available yet Пока недоступно OK ОК diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..17d677245 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,20 +1,19 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 292 / 491 items (59%)** · 436 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | | Edit | 13 | 11 | 85% | -| View | 75 | 31 | 41% | +| View | 75 | 36 | 48% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 64 | 75% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -68,14 +67,9 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Render > Light > New Distant Light - View > Render > Light > Light Glyphs - View > Render > Materials Browser -- View > UCS Icon > On - View > UCS Icon > Origin - View > UCS Icon > Properties... -- View > ViewCube > On - View > ViewCube > Settings -- View > Attribute Display > Normal -- View > Attribute Display > On -- View > Attribute Display > Off - View > Lights - View > Enter Full Screen - Insert > PDF Underlay... @@ -101,13 +95,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... From 7fe832b284e4eddab10d2b6f8755c467242f3809 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:01:54 -0700 Subject: [PATCH 09/91] MEASUREGEOM: a Volume option, and Tools > Inquiry > Volume MEASUREGEOM offered Distance, Radius and ARea, and Tools > Inquiry had no Volume item. The Volume option measures a polygon of three or more points, or a closed 2D object as AREA measures it, and asks for a height. It reports the area times the absolute value of the height. Add volume and Subtract volume keep a running total, as AREA's Add area and Subtract area do. Tools > Inquiry > Volume (volume.measure) starts the same prompts, and the JSON form takes points or a handle and a height. Engineering and architectural units show the volume as a decimal number. The de, fr and ru catalogs get a "Volume" row, and docs/parity-checklist.md is regenerated with cargo xtask parity. --- crates/engine/src/cmd/inquiry.rs | 91 ++++++++++++++++++++-- crates/engine/src/cmd/utility.rs | 13 +++- crates/engine/tests/it/inquiry_keywords.rs | 53 ++++++++++++- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + docs/parity-checklist.md | 13 +--- 7 files changed, 151 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 1f7f6f2ab..c6fdebc64 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -415,6 +415,34 @@ fn area_text(s: &Session, a: f64, per: f64) -> String { format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) } +/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a closed 2D +/// object (as AREA measures it), times the absolute value of a height. +pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result { + let a = if let Some(pts) = points_param(p, "points") { + if pts.len() < 3 { + return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points")); + } + area_of(&pts).0 + } else { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?; + object_area(s, h)?.0 + }; + let height = f64_req("volume.measure", p, "height")?; + let v = a * height.abs(); + Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) })) +} + +fn volume_text(s: &Session, v: f64) -> String { + format!("Volume = {}", fmt_volume(s, v)) +} + +/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units +/// show it as a decimal number, not in feet and inches. +fn fmt_volume(s: &Session, v: f64) -> String { + let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)); + format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p) +} + /// Area and perimeter of a closed object (AREA's Object option). pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> { let d = s.doc()?; @@ -889,9 +917,17 @@ pub(super) struct AreaM { object: bool, /// Asking for the length of a segment (the Length option). length: bool, + /// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height. + volume: bool, + /// Areas measured in volume mode that still wait for their height, oldest first. + pending: Vec, } impl AreaM { + /// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area. + pub(super) fn volume() -> Self { + AreaM { volume: true, ..AreaM::default() } + } fn mode_prefix(&self) -> &'static str { match self.mode { AreaMode::Single => "", @@ -899,8 +935,29 @@ impl AreaM { AreaMode::Subtract => "(SUBTRACT mode) ", } } + /// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and + /// "Subtract volume". + fn mode_keywords(&self) -> (&'static str, &'static str) { + if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") } + } + /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. + fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { + s.echo(volume_text(s, v)); + match self.mode { + AreaMode::Single => return Step::Done, + AreaMode::Add => self.total += v, + AreaMode::Subtract => self.total -= v, + } + s.echo(format!("Total volume = {}", fmt_volume(s, self.total))); + Step::Continue + } /// Report an area (and the running total in add/subtract mode); ends the command in single mode. + /// In volume mode the area waits for its height instead. fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step { + if self.volume { + self.pending.push(a); + return Step::Continue; + } s.echo(area_text(s, a, per)); match self.mode { AreaMode::Single => return Step::Done, @@ -923,9 +980,12 @@ impl AreaM { impl Interactive for AreaM { fn name(&self) -> &'static str { - "AREA" + if self.volume { "MEASUREGEOM" } else { "AREA" } } fn prompt(&self, _s: &Session) -> Prompt { + if !self.pending.is_empty() { + return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER); + } if self.object { return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); } @@ -933,17 +993,28 @@ impl Interactive for AreaM { return length_prompt(); } let next = format!("{}Specify next point", self.mode_prefix()); + let (add, subtract) = self.mode_keywords(); match (self.pts.len(), self.mode) { - (0, AreaMode::Single) => { - Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object") - } - (0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]), - (0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]), + (0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"), + (0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]), + (0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]), (1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()), _ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if !self.pending.is_empty() { + if let Input::Text(t) = i { + match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => { + let a = self.pending.remove(0); + return Ok(self.volume_measured(s, a * h.abs())); + } + None => s.echo("Requires a distance."), + } + } + return Ok(Step::Continue); + } if self.object { match i { Input::Pick(hs) => { @@ -953,6 +1024,10 @@ impl Interactive for AreaM { if self.measured(s, a, per) == Step::Done { return Ok(Step::Done); } + // A single volume takes the first object with an area. + if self.volume && self.mode == AreaMode::Single { + break; + } } Err(_) => s.echo("The selected object has no area."), } @@ -980,8 +1055,8 @@ impl Interactive for AreaM { Input::Point(p) => self.pts.push(p), Input::Keyword(k) => match k.as_str() { "Object" => self.object = true, - "Add area" => self.mode = AreaMode::Add, - "Subtract area" => self.mode = AreaMode::Subtract, + "Add area" | "Add volume" => self.mode = AreaMode::Add, + "Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract, "Undo" => { self.pts.pop(); } diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 89c00f7a6..be5db348c 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -27,11 +27,16 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::::default())), CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"), CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom) - .params("{mode: distance|radius|angle|area, ...}") + .params("{mode: distance|radius|angle|area|volume, ...}") .noundo() .interactive(|_| Ok(Box::::default())), CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(), CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(), + CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume) + .menu(&["Tools", "Inquiry", "Volume"]) + .params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}") + .noundo() + .interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))), ] } @@ -318,7 +323,8 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result { "radius" | "r" => run_radius(s, p), "angle" | "a" => run_angle(s, p), "area" | "ar" => s.execute("area", p), - _ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")), + "volume" | "v" => super::inquiry::run_volume(s, p), + _ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")), } } @@ -339,7 +345,7 @@ impl Interactive for MeasureM { Some(m) => m.prompt(s), None if self.radius => Prompt::new("Select arc or circle", Accept::POINT), None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true }) - .kw(&["Distance", "Radius", "ARea"]) + .kw(&["Distance", "Radius", "ARea", "Volume"]) .default("Distance"), } } @@ -366,6 +372,7 @@ impl Interactive for MeasureM { Input::Keyword(k) => match k.as_str() { "Radius" => self.radius = true, "ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())), + "Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())), _ => self.inner = Some(Box::new(super::inquiry::DistM::default())), }, _ => {} diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 920dd7ee5..166b374ef 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -1,4 +1,5 @@ -//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line. +//! AREA's Object / Add area / Subtract area, MEASUREGEOM's Volume and DIST's Multiple points, typed as +//! at the command line. use cadcraft_engine::Session; use serde_json::json; @@ -103,3 +104,53 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() { let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]); assert!(has(&log, "Perimeter = 46.0262"), "{log:?}"); } + +#[test] +fn measuregeom_volume_multiplies_an_area_by_a_height() { + // Points: a 4 x 3 triangle's area is 6, times a height of 10. + let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]); + assert!(has(&log, "Volume = 60.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Object (the default): a 10 x 10 square polyline, height 2. + let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]); + assert!(has(&log, "Volume = 200.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // A height that is not a distance is asked for again. + let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]); + assert!(has(&log, "Requires a distance."), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + + // The JSON forms, by points and by handle. + let mut s = Session::new(); + let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap(); + assert_eq!(r["volume"], json!(60.0)); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap(); + assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}"); + assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err()); + assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err()); +} + +#[test] +fn tools_inquiry_volume_adds_and_subtracts_a_running_total() { + let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure"); + assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]); + + // Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5. + let mut s = Session::new(); + s.cmdline("rectang 20,0 22,2").unwrap(); + s.start("volume.measure").unwrap(); + for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] { + s.cmdline(l).unwrap(); + } + assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log); + assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Architectural units show the volume as a number, not as a feet-and-inches length. + let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]); + assert!(has(&log, "Volume = 1200"), "{log:?}"); + assert!(!has(&log, "'-"), "{log:?}"); +} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e2e92d4c8..daef6939e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -967,6 +967,7 @@ Viewport Properties Ansichtsfenster-Eigenschaften Viewport {id} Ansichtsfenster {id} Viewports Ansichtsfenster + Volume Volumen W W WCS WKS White Weiß diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d8b100711..65264262c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -961,6 +961,7 @@ Viewport Properties Propriétés de la fenêtre Viewport {id} Fenêtre {id} Viewports Fenêtres + Volume Volume W O WCS SCG White Blanc diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c7a6baaf2..fd97e4589 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -961,6 +961,7 @@ Viewport Properties Свойства видового экрана Viewport {id} Видовой экран {id} Viewports Видовые экраны + Volume Объём W З WCS МСК White Белый diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..0f331eb9d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,11 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| @@ -14,7 +13,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 65 | 76% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -87,7 +86,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Format > Transparency - Format > Thickness - Tools > Spelling -- Tools > Inquiry > Volume - Tools > Update Fields - Tools > Edit Xref in Place - Tools > New UCS > World @@ -101,13 +99,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... From 1b8be0b77d369276fc14ef7d1c261ee4efea0548 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:56:55 -0700 Subject: [PATCH 10/91] COMPAREEXPORT: a named block the other drawing defines differently is copied as PART_2 A block reference of the other drawing whose named block exists in this drawing with other contents drew this drawing's definition in the red group. The export now keeps this drawing's block only when both drawings' blocks draw the same thing: the same objects, base point, units, annotative and overlay flags and xref file, in the block and in the blocks its references and multileaders use. Otherwise the other drawing's block is copied under a free name (PART_2, PART_3, ...) and its references point at the copy. --- crates/engine/src/cmd/file/compare.rs | 23 ++++- crates/engine/src/cmd/file/compare/export.rs | 28 ++++-- crates/engine/tests/compare_export.rs | 97 +++++++++++++++++++- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 2003d2079..32e1486f6 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -139,11 +139,19 @@ struct Signer<'a> { d: &'a Drawing, blocks: HashMap, work: usize, + /// When set, a block's signature also covers its base point, units, annotative and overlay + /// flags and xref file, with a relative xref path taken from the folder given, and a + /// multileader's signature covers its block's. + frames: Option>, } impl<'a> Signer<'a> { fn new(d: &'a Drawing) -> Self { - Signer { d, blocks: HashMap::new(), work: 0 } + Signer { d, blocks: HashMap::new(), work: 0, frames: None } + } + /// A signer whose block signatures also cover what changes how a reference draws the block. + fn with_frames(d: &'a Drawing, folder: Option<&'a std::path::Path>) -> Self { + Signer { frames: Some(folder), ..Signer::new(d) } } fn geometry(&mut self, k: &EntityKind, depth: usize) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -162,6 +170,12 @@ impl<'a> Signer<'a> { if let EntityKind::Insert(i) = k { self.block(&i.block, depth).hash(&mut h); } + if self.frames.is_some() + && let EntityKind::MLeader(m) = k + && let Some(b) = &m.block + { + self.block(&b.block, depth).hash(&mut h); + } h.finish() } fn block(&mut self, name: &str, depth: usize) -> u64 { @@ -174,6 +188,13 @@ impl<'a> Signer<'a> { if depth < MAX_BLOCK_DEPTH && let Some(b) = self.d.block(name) { + if let Some(folder) = self.frames { + let mut xref = b.xref_path.clone(); + if let Some(x) = xref.as_mut() { + export::rebase_path(x, folder); + } + (format!("{:?}", b.base), b.units, b.annotative, b.xref_overlay, xref).hash(&mut h); + } for e in b.entities.iter() { if self.work >= MAX_BLOCK_WORK { break; diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 2ebae8b14..0e1aa4036 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -119,7 +119,7 @@ fn folder_of(path: Option<&str>) -> Option { } /// `p` as an absolute path from `folder` when it is relative. -fn rebase_path(p: &mut String, folder: Option<&Path>) { +pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) { if let Some(folder) = folder && !p.trim().is_empty() && Path::new(p.as_str()).is_relative() @@ -531,8 +531,11 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, type Renames = HashMap; /// Copy into `d` the blocks of `other` that `objs` refer to, directly or through other blocks. -/// A named block `d` already has keeps `d`'s definition. An anonymous block (`*D3`, `*U7`…) -/// whose name `d` already uses is copied under the next free name with the same prefix. Copied +/// A named block `d` already has keeps `d`'s definition when it draws the same thing there (the +/// same objects, base point, units, flags and xref file, in it and in the blocks it refers to); +/// otherwise the other drawing's block is copied as `PART_2` (or `PART_3`…). An anonymous block +/// (`*D3`, `*U7`…) whose name `d` already uses is copied under the next free name with the same +/// prefix. Copied /// objects get new handles, and relative file paths are rebased on `folder`. The flag is true /// when more than [`MAX_BLOCKS`] blocks or [`MAX_COPIED`] objects were needed and the rest were /// left out. @@ -547,6 +550,8 @@ fn copy_blocks<'a>( let mut seen = HashSet::new(); let mut needed: Vec> = Vec::new(); let mut left_out = false; + // `d`'s relative paths were made absolute from its own folder before the copy. + let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder)); while let Some(n) = frontier.pop() { let key = n.to_ascii_uppercase(); if seen.contains(&key) { @@ -558,7 +563,14 @@ fn copy_blocks<'a>( } seen.insert(key); let Some(b) = other.block(&n) else { continue }; - if d.block(&n).is_some() && !b.anonymous { + // Past the signers' work limit the signatures no longer cover every object, so the block + // is copied. + if !b.anonymous + && d.block(&n).is_some() + && mine.block(&n, 0) == theirs.block(&n, 0) + && mine.work < super::MAX_BLOCK_WORK + && theirs.work < super::MAX_BLOCK_WORK + { continue; } if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { @@ -577,8 +589,12 @@ fn copy_blocks<'a>( renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); } for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { - let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); - let free = (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())); + let free = if b.anonymous { + let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); + (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) + } else { + Some(free_name(&b.name, taken.len(), |n| taken.contains(&n.to_ascii_uppercase()))) + }; let Some(free) = free else { continue }; taken.insert(free.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), free); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 76e9ee9e8..1ec7278fd 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use cadcraft_engine::Session; use cadcraft_engine::doc::color::{Color, Rgb}; use cadcraft_engine::doc::geom::Vec3; -use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Space}; +use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space}; use serde_json::json; fn insert(block: &str) -> Insert { @@ -194,6 +194,101 @@ fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() { assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]); } +#[test] +fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() { + // PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader + // whose content is SYMBOL, which holds one line. + let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| { + let mut part = Block::new("PART"); + part.base = Vec3::new(part_base, 0.0, 0.0); + part.entities.push(Entity::new(Handle(first), line(part_end))); + part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB")))); + let leader = MLeader { + leaders: Vec::new(), + landing: Vec3::ZERO, + dogleg: 0.0, + text: None, + style: "Standard".into(), + arrow_size: 1.0, + spline: false, + block: Some(insert("SYMBOL")), + }; + part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader))); + let mut sub = Block::new("SUB"); + sub.base = Vec3::new(sub_base, 0.0, 0.0); + sub.entities.push(Entity::new(Handle(first + 3), line(2.0))); + let mut symbol = Block::new("SYMBOL"); + symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end))); + [part, sub, symbol] + }; + let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| { + let mut s = Session::new(); + for b in blocks(1.0, 0.0, 0.0, 3.0, 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + // A second reference, so the other drawing has one that differs even when PART does not. + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect(); + (s, referenced) + }; + // The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red. + let (s, referenced) = run(5.0, 0.0, 0.0, 3.0); + assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept"); + assert_eq!(ends(&s, "PART_2"), vec![5.0]); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); + assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]); + let d = s.doc().unwrap(); + assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings"); + // The blocks a copy of PART refers to. + let refers = |s: &Session| -> Vec { + s.doc() + .unwrap() + .block("PART_2") + .unwrap() + .entities + .iter() + .flat_map(|e| match &e.kind { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + }) + .collect() + }; + // Another base point on PART, another base point on the SUB inside it, or another SYMBOL + // in its multileader: PART is copied too, with the blocks that differ. + let (s, referenced) = run(1.0, 3.0, 0.0, 3.0); + assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0)); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); + assert_eq!(refers(&s), ["SUB", "SYMBOL"]); + let (s, _) = run(1.0, 0.0, 4.0, 3.0); + assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0)); + assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]); + let (s, _) = run(1.0, 0.0, 0.0, 6.0); + assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]); + assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]); + // The same definitions in both drawings: the red reference uses this drawing's PART. + let (s, referenced) = run(1.0, 0.0, 0.0, 3.0); + assert!(s.doc().unwrap().block("PART_2").is_none()); + assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]); + assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 746942613c5d782e2a511bc523a17960ff7df0dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:00:24 -0700 Subject: [PATCH 11/91] CUTBASE: a base point is required, and a handle listed twice is cut once cutbase and copybase now fail with "`base` ([x, y]) is required" when `base` is missing or not a point, as their parameters say, instead of taking the lower-left corner of the objects. cutbase checks it before erasing anything. Copy, Copy with Base Point, Cut and Cut with Base Point put an object on the clipboard once however often `handles` lists it, so the counts they return match the objects copied or cut. --- crates/engine/src/cmd/edit.rs | 12 +++++++----- crates/engine/tests/clipboard.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 813a1c856..f6cb4412d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -438,9 +438,11 @@ impl Interactive for MredoM { } } +/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it. fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { let d = s.doc()?; - let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); + let mut seen = std::collections::HashSet::new(); + let ents: Vec = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0))); let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO }); let n = ents.len(); @@ -455,9 +457,9 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result { } fn run_copybase(s: &mut Session, p: &Value) -> Result { + let base = point_req("copybase", p, "base")?; let hs = targets(s, p)?; - let base = point_param(p, "base"); - let n = copy_to_clip(s, &hs, base)?; + let n = copy_to_clip(s, &hs, Some(base))?; Ok(json!({ "copied": n })) } @@ -480,9 +482,9 @@ fn run_cut(s: &mut Session, p: &Value) -> Result { } fn run_cutbase(s: &mut Session, p: &Value) -> Result { + let base = point_req("cutbase", p, "base")?; let hs = targets(s, p)?; - let base = point_param(p, "base"); - let n = cut_to_clip(s, &hs, base)?; + let n = cut_to_clip(s, &hs, Some(base))?; Ok(json!({ "cut": n })) } diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 606f2d190..c410db91c 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -201,6 +201,29 @@ fn cutbase_cuts_with_the_given_base_point() { assert_eq!(s.doc().unwrap().model.len(), 2); } +#[test] +fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs = handles(&s); + for cmd in ["cutbase", "copybase"] { + for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}"); + } + } + assert_eq!(model(&s).len(), 2, "nothing was cut"); + assert!(s.clipboard.is_empty(), "nothing was copied"); + + // A handle listed twice is copied and cut once. + let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] }); + assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 1); +} + #[test] fn cutbase_asks_for_the_base_point_then_the_objects() { let mut s = Session::empty(); From dc081c6f7f8d7448e05f1e6595b64abf42ccd85c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:20:47 -0700 Subject: [PATCH 12/91] MEASUREGEOM: add an Angle option, and Tools > Inquiry > Angle asks for the objects MEASUREGEOM typed at the command line offered Distance, Radius and ARea, and Tools > Inquiry > Angle ran without a prompt, so it failed asking for {vertex,p1,p2} or {h1,h2}. MEASUREGEOM now has an Angle option, and it and Tools > Inquiry > Angle ask "Select arc, circle, line ". An arc reports its included angle. A circle takes the pick as the first endpoint, uses the center as the vertex, and asks for the second endpoint. Two lines or polyline line segments report the angle where the lines meet, extended. Enter asks for a vertex and two endpoints. The JSON form of angle.measure is unchanged. --- crates/engine/src/cmd/annotate.rs | 2 +- crates/engine/src/cmd/utility.rs | 140 +++++++++++++++++++-- crates/engine/tests/it/inquiry_keywords.rs | 59 ++++++++- 3 files changed, 186 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 4d7bd1b7b..87b0d063c 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -897,7 +897,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option { } /// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. -fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { +pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { match &s.doc().ok()?.entity(h)?.kind { EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))), EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 89c00f7a6..130117c4e 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -1,7 +1,7 @@ //! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM. use cadcraft_doc::{EntityKind, Handle}; -use cadcraft_geom::Vec2; +use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; use super::*; @@ -31,7 +31,11 @@ pub fn specs() -> Vec { .noundo() .interactive(|_| Ok(Box::::default())), CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(), - CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(), + CommandSpec::new("angle.measure", "Angle", run_angle) + .menu(&["Tools", "Inquiry", "Angle"]) + .params("{vertex, p1, p2} | {h1, h2}") + .noundo() + .interactive(|_| Ok(Box::::default())), ] } @@ -299,17 +303,29 @@ fn run_angle(s: &mut Session, p: &Value) -> Result { Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())), _ => None, }; - let ((a1, a2), (b1, b2)) = + let (l1, l2) = (line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?); - let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?; - let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q }; - (x, far(a1, a2), far(b1, b2)) + lines_vertex(l1, l2).ok_or_else(|| bad("angle.measure", "lines are parallel"))? }; - let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees(); - if ang > 180.0 { - ang = 360.0 - ang; - } - Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") })) + let ang = vertex_angle(v, a, b); + Ok(json!({ "angle": ang, "message": angle_text(ang) })) +} + +/// The point where two lines (extended) meet and the end of each line farther from it; `None` when they are parallel. +fn lines_vertex((a1, a2): (Vec2, Vec2), (b1, b2): (Vec2, Vec2)) -> Option<(Vec2, Vec2, Vec2)> { + let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?; + let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q }; + Some((x, far(a1, a2), far(b1, b2))) +} + +/// The angle at `v` between the directions to `a` and `b`, in degrees from 0 to 180. +fn vertex_angle(v: Vec2, a: Vec2, b: Vec2) -> f64 { + let ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees(); + if ang > 180.0 { 360.0 - ang } else { ang } +} + +fn angle_text(ang: f64) -> String { + format!("Angle = {ang:.4}°") } fn run_measuregeom(s: &mut Session, p: &Value) -> Result { @@ -322,7 +338,7 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result { } } -/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA or a radius pick. +/// MEASUREGEOM typed or picked: choose what to measure, then continue as DIST, AREA, a radius pick or an angle pick. #[derive(Default)] struct MeasureM { inner: Option>, @@ -339,7 +355,7 @@ impl Interactive for MeasureM { Some(m) => m.prompt(s), None if self.radius => Prompt::new("Select arc or circle", Accept::POINT), None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true }) - .kw(&["Distance", "Radius", "ARea"]) + .kw(&["Distance", "Radius", "Angle", "ARea"]) .default("Distance"), } } @@ -365,6 +381,7 @@ impl Interactive for MeasureM { Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())), Input::Keyword(k) => match k.as_str() { "Radius" => self.radius = true, + "Angle" => self.inner = Some(Box::::default()), "ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())), _ => self.inner = Some(Box::new(super::inquiry::DistM::default())), }, @@ -377,6 +394,103 @@ impl Interactive for MeasureM { } } +/// MEASUREGEOM's Angle option and Tools > Inquiry > Angle: the angle of an arc, of two points on a +/// circle about its center, between two lines or polyline line segments, or (Enter) at a vertex +/// between two endpoints. +#[derive(Default)] +struct AngleM { + /// The first line picked. + first: Option<(Vec2, Vec2)>, + /// The vertex and endpoints given so far (Enter, or a circle's center and the point picked on it). + vertex: Option>, +} + +impl AngleM { + fn finish(s: &mut Session, ang: f64) -> Result { + s.echo(angle_text(ang)); + Ok(Step::Done) + } +} + +impl Interactive for AngleM { + fn name(&self) -> &'static str { + "MEASUREGEOM" + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(pts) = &self.vertex { + let msg = ["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"].get(pts.len()).copied().unwrap_or(""); + return Prompt::new(msg, Accept::POINT).base_opt(pts.first().copied()); + } + match self.first { + None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"), + Some(_) => Prompt::new("Select second line", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(pts) = &mut self.vertex { + return match i { + Input::Point(p) => { + pts.push(p); + match pts.as_slice() { + [v, a, b] => Self::finish(s, vertex_angle(*v, *a, *b)), + _ => Ok(Step::Continue), + } + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + } + let p = match i { + Input::Enter if self.first.is_none() => { + self.vertex = Some(Vec::new()); + return Ok(Step::Continue); + } + Input::Point(p) => p, + Input::Enter => return Ok(Step::Cancel), + _ => return Ok(Step::Continue), + }; + let Some(h) = super::curves::pick_at(s, p) else { + s.echo("*Invalid selection*"); + return Ok(Step::Continue); + }; + let circle = s + .doc()? + .entity(h) + .and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None }); + if let Some(c) = circle.filter(|_| self.first.is_none()) { + self.vertex = Some(vec![c.center, c.closest(p)]); + return Ok(Step::Continue); + } + match super::annotate::segment_at(s, h, p) { + Some(Segment::Arc { arc, .. }) if self.first.is_none() => Self::finish(s, arc.sweep().to_degrees()), + Some(Segment::Line(l)) => match self.first { + None => { + self.first = Some((l.a, l.b)); + Ok(Step::Continue) + } + Some(first) => match lines_vertex(first, (l.a, l.b)) { + Some((v, a, b)) => Self::finish(s, vertex_angle(v, a, b)), + None => { + s.echo("The lines are parallel."); + Ok(Step::Done) + } + }, + }, + _ => { + s.echo(if self.first.is_none() { "Select an arc, circle or line." } else { "Select a line." }); + Ok(Step::Continue) + } + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + match self.vertex.as_deref() { + Some([v]) => vec![super::helpers::line(*v, c)], + Some([v, a]) => vec![super::helpers::line(*v, *a), super::helpers::line(*v, c)], + _ => Vec::new(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 920dd7ee5..9988ca831 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -1,4 +1,4 @@ -//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line. +//! AREA's Object / Add area / Subtract area, DIST's Multiple points and MEASUREGEOM's Angle, typed as at the command line. use cadcraft_engine::Session; use serde_json::json; @@ -88,6 +88,63 @@ fn measuregeom_typed_asks_for_the_option_then_the_points() { assert!(has(&log, "Area = 6.0000"), "{log:?}"); } +#[test] +fn measuregeom_angle_measures_an_arc_a_circle_two_lines_and_a_vertex() { + let typed = |s: &mut Session, lines: &[&str]| { + for l in lines { + s.cmdline(l).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + s.log.clone() + }; + + // Arc: the angle between its endpoints about its center. + let mut s = Session::new(); + s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 270})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "-2,0"]), "Angle = 270.0000°")); + + // Circle: the pick on the circle and a second point, about the center. + let log = typed(&mut Session::new(), &["circle 0,0 2", "measuregeom", "a", "2,0", "0,5"]); + assert!(has(&log, "Angle = 90.0000°"), "{log:?}"); + + // Two lines that do not meet, and two segments of one polyline. + let mut s = Session::new(); + s.execute("line", &json!({"points": [[1, 0], [10, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 1], [5, 6]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "2,3"]), "Angle = 45.0000°"), "{:?}", s.log); + let mut s = Session::new(); + s.execute("pline", &json!({"vertices": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "10,5"]), "Angle = 90.0000°"), "{:?}", s.log); + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap(); + assert!(has(&typed(&mut s, &["measuregeom", "a", "5,0", "5,5"]), "The lines are parallel."), "{:?}", s.log); + + // Enter: a vertex and two endpoints. + let log = typed(&mut Session::new(), &["measuregeom", "a", "", "0,0", "1,0", "-1,1"]); + assert!(has(&log, "Angle = 135.0000°"), "{log:?}"); + + // Empty space is not an object, and the prompt stays. + let (s, log) = run(&["measuregeom", "a", "50,50"]); + assert!(has(&log, "*Invalid selection*"), "{log:?}"); + assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text()); +} + +#[test] +fn tools_inquiry_angle_asks_for_the_objects() { + let spec = cadcraft_engine::find_command("angle.measure").expect("no command angle.measure"); + assert_eq!(spec.menu, ["Tools", "Inquiry", "Angle"]); + let mut s = Session::new(); + s.cmdline("circle 0,0 2").unwrap(); + s.start("angle.measure").unwrap(); + assert!(s.prompt_text().contains("Select arc, circle, line"), "{}", s.prompt_text()); + for l in ["0,2", "-3,0"] { + s.cmdline(l).unwrap(); + } + assert!(has(&s.log, "Angle = 90.0000°"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + #[test] fn area_of_an_ellipse_reports_its_exact_perimeter() { // References from the arithmetic-geometric mean (Gauss–Kummer), to 12 decimals. A closed-form From 596ea36465e5221d074ea7ea84564782c73ca765 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:45:53 -0700 Subject: [PATCH 13/91] MEASUREGEOM: malformed points and volumes past f64's range are errors AREA and Volume measured the selected object when `points` was given but was not a list of points; they now report an error, and only an absent or null `points` measures the selection. A volume too large for f64 (an area times a height) is an error in the JSON form. At the typed prompt, a volume or an Add/Subtract volume running total too large for f64 makes the prompt say "The volume is too large." and ask for the height again, where it reported "Volume = 0" or "Total volume = 0". --- crates/engine/src/cmd/inquiry.rs | 46 ++++++++++++++++------ crates/engine/tests/it/inquiry_keywords.rs | 43 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index c6fdebc64..26f074a96 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -401,8 +401,17 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) { (a, per) } +/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an +/// error, never a fall back to the selection. +fn given_points(p: &Value, cmd: &str) -> Result>> { + match p.get("points") { + None | Some(Value::Null) => Ok(None), + Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")), + } +} + fn run_area(s: &mut Session, p: &Value) -> Result { - let (a, per) = if let Some(pts) = points_param(p, "points") { + let (a, per) = if let Some(pts) = given_points(p, "area")? { area_of(&pts) } else { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?; @@ -415,10 +424,10 @@ fn area_text(s: &Session, a: f64, per: f64) -> String { format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) } -/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a closed 2D -/// object (as AREA measures it), times the absolute value of a height. +/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object +/// as AREA measures it (an open polyline as if closed), times the absolute value of a height. pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result { - let a = if let Some(pts) = points_param(p, "points") { + let a = if let Some(pts) = given_points(p, "volume.measure")? { if pts.len() < 3 { return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points")); } @@ -429,6 +438,9 @@ pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result { }; let height = f64_req("volume.measure", p, "height")?; let v = a * height.abs(); + if !v.is_finite() { + return Err(bad("volume.measure", "the volume is too large")); + } Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) })) } @@ -940,14 +952,21 @@ impl AreaM { fn mode_keywords(&self) -> (&'static str, &'static str) { if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") } } + /// The running total with `v` added or subtracted; in single mode, the total as it is. + fn total_with(&self, v: f64) -> f64 { + match self.mode { + AreaMode::Single => self.total, + AreaMode::Add => self.total + v, + AreaMode::Subtract => self.total - v, + } + } /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { s.echo(volume_text(s, v)); - match self.mode { - AreaMode::Single => return Step::Done, - AreaMode::Add => self.total += v, - AreaMode::Subtract => self.total -= v, + if self.mode == AreaMode::Single { + return Step::Done; } + self.total = self.total_with(v); s.echo(format!("Total volume = {}", fmt_volume(s, self.total))); Step::Continue } @@ -1006,10 +1025,13 @@ impl Interactive for AreaM { if !self.pending.is_empty() { if let Input::Text(t) = i { match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => { - let a = self.pending.remove(0); - return Ok(self.volume_measured(s, a * h.abs())); - } + Some(h) => match self.pending.first().map(|a| a * h.abs()) { + Some(v) if v.is_finite() && self.total_with(v).is_finite() => { + self.pending.remove(0); + return Ok(self.volume_measured(s, v)); + } + _ => s.echo("The volume is too large."), + }, None => s.echo("Requires a distance."), } } diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 166b374ef..49b807a57 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -133,6 +133,49 @@ fn measuregeom_volume_multiplies_an_area_by_a_height() { assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err()); } +#[test] +fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() { + let mut s = Session::new(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]); + for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] { + assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}"); + assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}"); + } + // Without `points`, both measure the selected circle. + let r = s.execute("volume.measure", &json!({"height": 1})).unwrap(); + assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}"); + let r = s.execute("area", &json!({"points": null})).unwrap(); + assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}"); +} + +#[test] +fn measuregeom_volume_too_large_to_hold_is_refused() { + // A 2e150 square has an area of 4e300; times 1e10 it is past f64's range. + let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]); + let mut s = Session::new(); + assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err()); + assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok()); + + // Typed, the height is asked for again. + let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]); + assert!(has(&log, "The volume is too large."), "{log:?}"); + assert!(!has(&log, "Volume = "), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + + // Two volumes that each fit can overflow the running total; the second is asked for again. + let sq = ["0,0", "1,0", "1,1", "0,1", ""]; + let mut lines = vec!["measuregeom", "v", "a"]; + lines.extend(sq); + lines.push("1e308"); + lines.extend(sq); + lines.push("1e308"); + let (s, log) = run(&lines); + assert!(has(&log, "The volume is too large."), "{log:?}"); + assert!(!has(&log, "Total volume = 0"), "{log:?}"); + assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); +} + #[test] fn tools_inquiry_volume_adds_and_subtracts_a_running_total() { let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure"); From 432cbfb106ac5352834eb9a12c21dd22faf290f1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:24:50 -0700 Subject: [PATCH 14/91] COMPAREEXPORT: a named block whose dimension draws another block is copied as PART_2 A named block that holds a dimension was kept as this drawing's definition when only the block the dimension draws differed between the drawings, so the red group drew this drawing's dimension graphics. The export's block comparison now covers a dimension's block, as it covers a multileader's. --- crates/engine/src/cmd/file/compare.rs | 10 ++++- crates/engine/tests/compare_export.rs | 63 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 32e1486f6..bccd7b2d8 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -140,8 +140,8 @@ struct Signer<'a> { blocks: HashMap, work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay - /// flags and xref file, with a relative xref path taken from the folder given, and a - /// multileader's signature covers its block's. + /// flags and xref file, with a relative xref path taken from the folder given, and the + /// signatures of a multileader and a dimension cover their blocks'. frames: Option>, } @@ -176,6 +176,12 @@ impl<'a> Signer<'a> { { self.block(&b.block, depth).hash(&mut h); } + if self.frames.is_some() + && let EntityKind::Dimension(dm) = k + && let Some(b) = &dm.block + { + self.block(b, depth).hash(&mut h); + } h.finish() } fn block(&mut self, name: &str, depth: usize) -> u64 { diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 1ec7278fd..d277efb2c 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -289,6 +289,69 @@ fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_defini assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]); } +#[test] +fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`, + // holds a line of another length in the other drawing. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let blocks = |dim_end: f64, first: u64| { + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone()))); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end))); + [part, drawn] + }; + let mut s = Session::new(); + for b in blocks(1.0, 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks(5.0, 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept"); + let d = s.doc().unwrap(); + let drawn: Vec> = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); + assert_eq!(ends(&s, "*D2"), vec![5.0]); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From bfa30e219299ab2471ee6c32d427f42a35225f97 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:28:00 -0700 Subject: [PATCH 15/91] MEASUREGEOM: Tools > Inquiry > Radius asks for the object, and Radius measures polyline arcs Tools > Inquiry > Radius had no prompts, so with nothing selected it stopped with "`handle` is required". It now asks "Select arc or circle" and reports the radius and diameter of the arc or circle picked. A selected arc or circle is still measured at once. MEASUREGEOM's Radius option and Tools > Inquiry > Radius also measure the arc segment of a polyline under the pick. radius.measure takes an optional `point`, and for a polyline it measures the arc segment nearest that point. --- crates/engine/src/cmd/annotate.rs | 2 +- crates/engine/src/cmd/utility.rs | 82 ++++++++++++++++++---- crates/engine/tests/it/inquiry_keywords.rs | 50 +++++++++++++ 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 4d7bd1b7b..87b0d063c 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -897,7 +897,7 @@ fn pick_at(s: &Session, p: Vec2) -> Option { } /// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. -fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { +pub(super) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { match &s.doc().ok()?.entity(h)?.kind { EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))), EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 89c00f7a6..3a4a2ab3d 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -1,7 +1,7 @@ //! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM. use cadcraft_doc::{EntityKind, Handle}; -use cadcraft_geom::Vec2; +use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; use super::*; @@ -30,7 +30,11 @@ pub fn specs() -> Vec { .params("{mode: distance|radius|angle|area, ...}") .noundo() .interactive(|_| Ok(Box::::default())), - CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(), + CommandSpec::new("radius.measure", "Radius", run_radius) + .menu(&["Tools", "Inquiry", "Radius"]) + .params("{handle, point?}") + .noundo() + .interactive(|_| Ok(Box::new(RadiusM))), CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(), ] } @@ -283,11 +287,70 @@ fn run_radius(s: &mut Session, p: &Value) -> Result { let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) { Some(EntityKind::Circle(c)) => c.radius, Some(EntityKind::Arc(a)) => a.radius, - _ => return Err(bad("radius.measure", "select an arc or circle")), + Some(EntityKind::LwPolyline(_)) => { + let at = point_param(p, "point").ok_or_else(|| bad("radius.measure", "`point` on the polyline's arc segment is required"))?; + match super::annotate::segment_at(s, h, at) { + Some(Segment::Arc { arc, .. }) => arc.radius, + _ => return Err(bad("radius.measure", "the polyline segment at `point` is not an arc")), + } + } + _ => return Err(bad("radius.measure", "select an arc, circle or polyline arc")), }; Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) })) } +/// MEASUREGEOM's Radius option and Tools > Inquiry > Radius: the radius and diameter of a picked +/// arc, circle or polyline arc segment. Tools > Inquiry > Radius measures a selected arc or circle +/// at once. +struct RadiusM; + +impl RadiusM { + /// Measure the arc, circle or polyline arc segment at the picked point; a pick on anything else keeps the prompt. + fn pick(s: &mut Session, i: Input) -> Result { + let Input::Point(p) = i else { return Ok(Step::Done) }; + let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) }; + match run_radius(s, &json!({ "handle": h.0, "point": [p.x, p.y] })) { + Ok(r) => { + echo_message(s, &r); + Ok(Step::Done) + } + Err(_) => { + s.echo("Select an arc, circle or polyline arc."); + Ok(Step::Continue) + } + } + } +} + +impl Interactive for RadiusM { + fn name(&self) -> &'static str { + "MEASUREGEOM" + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Select arc or circle", Accept::POINT) + } + fn begin(&mut self, s: &mut Session) -> Result { + let Some(h) = s.selection().first().copied() else { return Ok(Step::Continue) }; + match run_radius(s, &json!({ "handle": h.0 })) { + Ok(r) => { + echo_message(s, &r); + Ok(Step::Done) + } + Err(_) => Ok(Step::Continue), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + Self::pick(s, i) + } +} + +/// Echo each line of a result's `message`. +fn echo_message(s: &mut Session, r: &Value) { + for l in r["message"].as_str().unwrap_or_default().lines() { + s.echo(l.to_string()); + } +} + fn run_angle(s: &mut Session, p: &Value) -> Result { let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) { (v, a, b) @@ -348,18 +411,7 @@ impl Interactive for MeasureM { return m.input(s, i); } if self.radius { - let Input::Point(p) = i else { return Ok(Step::Done) }; - let Some(h) = super::curves::pick_at(s, p) else { return Ok(Step::Continue) }; - match run_radius(s, &json!({ "handle": h.0 })) { - Ok(r) => { - for l in r["message"].as_str().unwrap_or_default().lines() { - s.echo(l.to_string()); - } - return Ok(Step::Done); - } - Err(_) => s.echo("Select an arc or circle."), - } - return Ok(Step::Continue); + return RadiusM::pick(s, i); } match i { Input::Enter => self.inner = Some(Box::new(super::inquiry::DistM::default())), diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 920dd7ee5..a8a18e6ff 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -74,6 +74,56 @@ fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() { assert!(!has(&log, "Area = "), "{log:?}"); } +#[test] +fn tools_inquiry_radius_asks_for_the_object() { + // Nothing selected: the menu item asks for the arc or circle. + let mut s = Session::new(); + s.cmdline("circle 0,0 2").unwrap(); + s.start("radius.measure").unwrap(); + assert!(s.prompt_text().contains("Select arc or circle"), "{}", s.prompt_text()); + s.cmdline("2,0").unwrap(); + assert!(has(&s.log, "Radius = 2.0000"), "{:?}", s.log); + assert!(has(&s.log, "Diameter = 4.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // A selected arc is measured at once. + let mut s = Session::new(); + let r = s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 0, "end": 90})).unwrap(); + s.set_selection(vec![cadcraft_doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()]); + s.start("radius.measure").unwrap(); + assert!(has(&s.log, "Radius = 3.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn measuregeom_radius_measures_a_polyline_arc_segment() { + // A line from 0,0 to 10,0, then a half circle of radius 5 to 10,10 through 15,5. + let pline = json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 1}, [10, 10]]}); + let mut s = Session::new(); + s.execute("pline", &pline).unwrap(); + s.cmdline("measuregeom").unwrap(); + s.cmdline("r").unwrap(); + // A pick on the line segment keeps the prompt. + s.cmdline("5,0").unwrap(); + assert!(has(&s.log, "Select an arc, circle or polyline arc."), "{:?}", s.log); + assert!(s.running.is_some()); + s.cmdline("15,5").unwrap(); + assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log); + assert!(has(&s.log, "Diameter = 10.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // The same pick from Tools > Inquiry > Radius, and the JSON form with the point on the arc. + let mut s = Session::new(); + let h = s.execute("pline", &pline).unwrap()["handle"].clone(); + s.start("radius.measure").unwrap(); + s.cmdline("15,5").unwrap(); + assert!(has(&s.log, "Radius = 5.0000"), "{:?}", s.log); + let r = s.execute("radius.measure", &json!({"handle": h, "point": [15, 5]})).unwrap(); + assert_eq!(r["radius"].as_f64(), Some(5.0), "{r}"); + assert!(s.execute("radius.measure", &json!({"handle": h, "point": [5, 0]})).is_err()); + assert!(s.execute("radius.measure", &json!({"handle": h})).is_err()); +} + #[test] fn measuregeom_typed_asks_for_the_option_then_the_points() { // Distance is the default option (#1103: typed, it used to fail asking for `p1`). From 898b539630af2decc5011b8f9515c7f826f11849 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:50:33 -0700 Subject: [PATCH 16/91] COMPAREEXPORT: a block left out at the block limit is reported when this drawing's differs Past the limit on blocks copied, a block of the other drawing that this drawing also defines was skipped without a note, and the red group drew this drawing's definition even when the two differed. Such a block now counts as left out unless both drawings' blocks draw the same thing, so the export says that some blocks were too many to copy. --- crates/engine/src/cmd/file/compare/export.rs | 20 +++++++------ crates/engine/tests/compare_export.rs | 30 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 0e1aa4036..9d24c1aef 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -552,25 +552,27 @@ fn copy_blocks<'a>( let mut left_out = false; // `d`'s relative paths were made absolute from its own folder before the copy. let (mut mine, mut theirs) = (super::Signer::with_frames(d, None), super::Signer::with_frames(other, folder)); + // Whether `d`'s block `n` draws what `other`'s block `b` draws. Past the signers' work limit + // the signatures no longer cover every object, so the blocks count as different. + let mut same = |n: &str, b: &Block| { + !b.anonymous + && d.block(n).is_some() + && mine.block(n, 0) == theirs.block(n, 0) + && mine.work < super::MAX_BLOCK_WORK + && theirs.work < super::MAX_BLOCK_WORK + }; while let Some(n) = frontier.pop() { let key = n.to_ascii_uppercase(); if seen.contains(&key) { continue; } if seen.len() >= MAX_BLOCKS { - left_out |= other.block(&n).is_some() && d.block(&n).is_none(); + left_out |= other.block(&n).is_some_and(|b| !same(&n, b)); continue; } seen.insert(key); let Some(b) = other.block(&n) else { continue }; - // Past the signers' work limit the signatures no longer cover every object, so the block - // is copied. - if !b.anonymous - && d.block(&n).is_some() - && mine.block(&n, 0) == theirs.block(&n, 0) - && mine.work < super::MAX_BLOCK_WORK - && theirs.work < super::MAX_BLOCK_WORK - { + if same(&n, b) { continue; } if tinted.copied.saturating_add(b.entities.len()) > MAX_COPIED { diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index d277efb2c..8a2b0aa47 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -586,6 +586,36 @@ fn says_when_blocks_of_the_other_drawing_were_left_out() { assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]); } +#[test] +fn says_when_blocks_both_drawings_define_differently_were_left_out() { + // Both drawings define B0 to B10000, each with a line of another length, so past the block + // limit the blocks left are ones this drawing defines differently. + let blocks = |end: f64, first: u64, d: &mut Drawing| { + for i in 0..10_001 { + let name = format!("B{i}"); + let mut b = Block::new(&name); + b.entities.push(Entity::new(Handle(first + i), line(end))); + d.blocks.insert(name, Arc::new(b)); + } + }; + let mut other = Drawing::new_imperial(); + blocks(1.0, 0x10_0000, &mut other); + for i in 0..10_001 { + other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap(); + } + other.handseed = other.handseed.max(0x20_0000); + let mut s = Session::new(); + let mut mine = s.doc().unwrap().clone(); + blocks(2.0, 0x30_0000, &mut mine); + mine.handseed = mine.handseed.max(0x40_0000); + s.open_drawing(mine, "Mine", None); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); +} + #[test] fn needs_a_comparison_of_the_current_drawing() { let mut s = Session::new(); From 8a64e42100056740d38562c9fd857048c0aa2b26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:15:32 -0700 Subject: [PATCH 17/91] DIMTFILL: dimension text gets a background in the drawing's background color or in DIMTFILLCLR DIMTFILL and DIMTFILLCLR were not modeled, so a dimension's text never had a background and lines under it stayed visible. Dimension styles now carry both variables. DIMTFILL 1 hides what is drawn under the text in the drawing's background color, and DIMTFILL 2 fills the box in DIMTFILLCLR. The box is the text box widened by DIMGAP on every side. The arc length symbol over the text and the DIMINSPECT frame are drawn over the background. The variables work with SETVAR, DIMOVERRIDE and the style fields. DXF stores them as DIMSTYLE groups 69 and 70 from R2007 and in the style's CADCRAFT xdata in older files; the reader skips the record's flags, which also use 70. For DIMTFILL 2, the dimension's *D block and EXPLODE add a SOLID in DIMTFILLCLR under the text. A dimension drawn from the *D block it was read with gets a background around each of the block's texts, before the block's first text or first segment of the text's marks, except for a text that a SOLID of the block already covers. The background stays after the drawing is saved and reopened. --- crates/doc/src/tables.rs | 11 +- crates/engine/src/cmd/modify.rs | 40 ++-- crates/engine/tests/it/dim_vars.rs | 28 +++ crates/io/src/dxf_ext.rs | 27 ++- crates/io/src/dxf_read.rs | 7 +- crates/io/src/dxf_write.rs | 33 ++- crates/io/tests/it/dimtfill_roundtrip.rs | 169 ++++++++++++++ crates/io/tests/it/main.rs | 1 + crates/render/src/dim.rs | 41 +++- crates/render/src/lib.rs | 144 +++++++++++- crates/render/tests/it/dim_text_fill.rs | 268 +++++++++++++++++++++++ crates/render/tests/it/main.rs | 1 + docs/gaps.md | 5 +- 13 files changed, 727 insertions(+), 48 deletions(-) create mode 100644 crates/io/tests/it/dimtfill_roundtrip.rs create mode 100644 crates/render/tests/it/dim_text_fill.rs diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 69badebb0..186a70a4a 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -177,6 +177,11 @@ pub struct DimStyle { pub dim_line_color: Color, pub ext_line_color: Color, pub text_color: Color, + /// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color, + /// 2 `text_fill_color`. + pub text_fill: u8, + /// DIMTFILLCLR: the text background color when `text_fill` is 2. + pub text_fill_color: Color, pub text_style: String, /// DIMPOST prefix/suffix "<>" template. pub post: String, @@ -296,6 +301,8 @@ pub const DIMVARS: &[(&str, &str)] = &[ ("DIMCLRD", "dimLineColor"), ("DIMCLRE", "extLineColor"), ("DIMCLRT", "textColor"), + ("DIMTFILL", "textFill"), + ("DIMTFILLCLR", "textFillColor"), ("DIMTXSTY", "textStyle"), ("DIMPOST", "post"), ("DIMCEN", "centerMark"), @@ -367,6 +374,8 @@ impl Default for DimStyle { dim_line_color: Color::ByBlock, ext_line_color: Color::ByBlock, text_color: Color::ByBlock, + text_fill: 0, + text_fill_color: Color::ByBlock, text_style: "Standard".into(), post: String::new(), center_mark: 0.09, @@ -489,7 +498,7 @@ impl DimStyle { "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), "scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0), "fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0), - "textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0), + "textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0), "dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)), "jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)), _ => false, diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 059beb6a1..942270d64 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1397,21 +1397,17 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option = g - .lines - .iter() - .filter_map(|l| { - if l.len() == 2 { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) - } else { - Some(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false), - }) - } - }) - .collect(); + let line_entity = |l: &Vec| { + if l.len() == 2 { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) + } else { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) + } + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + let text_marks = &g.text_marks; + let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); + let mut v: Vec = marks(false).filter_map(&line_entity).collect(); for t in &g.fills { if let [a, b, c] = t.as_slice() { v.push(Entity { @@ -1421,6 +1417,20 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option = + d.model.iter().enumerate().filter(|(i, e)| matches!(e.kind, EntityKind::Solid(_)) && Some(*i) != solid).map(|(i, _)| i).collect(); + assert!(!arrows.is_empty() && arrows.iter().all(|i| Some(*i) < solid), "{arrows:?} before {solid:?}"); +} + #[test] fn fit_moves_text_or_arrows_outside() { // 0.5 between the extension lines: the text "0.5000" doesn't fit, the arrows (2 × 0.18) do. diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 486a95421..392ee1400 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -118,6 +118,10 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[ ("arrowBlock2", 344, K::Block), ("fixedExtLength", 49, K::Real), ("jogAngle", 50, K::Angle), + // DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1, DIMSTYLE). In a + // DIMSTYLE record 70 also holds the record's flags, which come first. + ("textFill", 69, K::Int), + ("textFillColor", 70, K::Color), ("angularZeroSuppression", 79, K::Int), ("arcSymbol", 90, K::Int), ("textVerticalPos", 145, K::Real), @@ -143,14 +147,23 @@ pub(crate) const DIMSAH: i32 = 173; /// DIMPOST's group in DIMSTYLE records. pub(crate) const DIMPOST: i32 = 3; -/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1, -/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them -/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 ` then the value, like DSTYLE) instead of -/// groups an R2000 reader doesn't expect. The reader takes both. -pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347]; -/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose -/// files are the 2004 format (AC1018). +/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMTFILL, DIMTFILLCLR, DIMARCSYM, DIMFXLON, +/// DIMLTYPE, DIMLTEX1, DIMLTEX2. Files of a variable's [`newer_since`] version or later get it as +/// a DIMSTYLE group (R2004, and R2007 for DIMTFILL and DIMTFILLCLR); older files get it in a +/// style's `CADCRAFT` "DIMVARS" xdata (`1070 ` then the value, like DSTYLE) instead of a +/// group an older reader doesn't expect. The reader takes both. +pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 69, 70, 90, 290, 345, 346, 347]; +/// The first version with [`NEWER_CODES`] as DIMSTYLE groups, except DIMTFILL and DIMTFILLCLR +/// (see [`newer_since`]): the others came with AutoCAD 2006, whose files are the 2004 format +/// (AC1018). pub(crate) const NEWER_SINCE: Version = Version::R2004; + +/// The first version that has `code` (one of [`NEWER_CODES`]) as a DIMSTYLE group: R2007 for +/// DIMTFILL and DIMTFILLCLR (Open Design Specification for .dwg files 5.4.1), else +/// [`NEWER_SINCE`]. +pub(crate) fn newer_since(code: i32) -> Version { + if matches!(code, 69 | 70) { Version::R2007 } else { NEWER_SINCE } +} /// Xdata list name of [`NEWER_CODES`] values. pub(crate) const DIMVARS_LIST: &str = "DIMVARS"; diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5860b1e03..6a3ea222d 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1587,7 +1587,12 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { // Every variable with a DIMSTYLE group code; values the style can't take are // left at their defaults. let mut fields = serde_json::Map::new(); - for tag in tg.iter().take_while(|x| x.code < 1000) { + // The first 70 is the record's flags; a later one is DIMTFILLCLR. + let flags = tg.iter().position(|x| x.code == 70); + for (i, tag) in tg.iter().enumerate().take_while(|(_, x)| x.code < 1000) { + if Some(i) == flags { + continue; + } let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue }; if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs, &rx.ltypes) { fields.insert(field.to_string(), v); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index f21a28d85..2924a0078 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1393,16 +1393,21 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { // dimension's own colour (#1052). let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() }; let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color)); - for (i, l) in g.lines.iter().enumerate() { + let line_entities = |i: usize, l: &[Vec2]| -> Vec { let c = match g.line_roles.get(i) { Some(cadcraft_render::LineRole::Ext) => &ext_c, _ => &dim_c, }; - for seg in l.windows(2) { - if let [a, b] = seg { - out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }); - } - } + l.windows(2) + .filter_map(|seg| match seg { + [a, b] => Some(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }), + _ => None, + }) + .collect() + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) { + out.extend(line_entities(i, l)); } for t in &g.fills { if let [a, b, cc] = t.as_slice() { @@ -1413,6 +1418,20 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { }); } } + // DIMTFILL 2: the text background in DIMTFILLCLR, under the text and its marks (SOLID + // corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + out.push(Entity { + handle: Handle(0), + common: in_color(style.text_fill_color), + kind: EntityKind::Solid(Solid { corners: [p0.to3(0.0), p1.to3(0.0), p3.to3(0.0), p2.to3(0.0)] }), + }); + } + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) { + out.extend(line_entities(i, l)); + } if !g.value.is_empty() { out.push(Entity { handle: Handle(0), @@ -2291,7 +2310,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { continue; } // Table groups where the file version has them, else xdata. - if version >= dxf_ext::NEWER_SINCE { + if version >= dxf_ext::newer_since(*code) { w.dimval(*code, enc, false); } else if v.get(*field) != defaults.get(*field) { newer.i(1070, i64::from(*code)); diff --git a/crates/io/tests/it/dimtfill_roundtrip.rs b/crates/io/tests/it/dimtfill_roundtrip.rs new file mode 100644 index 000000000..2f574df5a --- /dev/null +++ b/crates/io/tests/it/dimtfill_roundtrip.rs @@ -0,0 +1,169 @@ +//! DIMTFILL (group 69) and DIMTFILLCLR (group 70) survive DXF: as DIMSTYLE groups from R2007, +//! after the record's own flags (also 70), and in the style's `CADCRAFT` "DIMVARS" xdata in older +//! files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID. + +use cadcraft_color::Color; +use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version}; + +fn filled() -> DimStyle { + DimStyle { name: "Fill".into(), text_fill: 2, text_fill_color: Color::Index(3), ..DimStyle::default() } +} + +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(filled()); + d +} + +fn dxf(d: &Drawing, v: Version) -> String { + String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &SaveOptions { version: Some(v), binary: false }).unwrap()).unwrap() +} + +/// The DIMSTYLE record named `name`, as trimmed lines. +fn record<'a>(text: &'a str, name: &str) -> Vec<&'a str> { + let l: Vec<&str> = text.lines().map(str::trim).collect(); + let at = l.windows(2).position(|w| w == ["2", name]).unwrap(); + let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e); + l[at..end].to_vec() +} + +#[test] +fn text_fill_roundtrips_in_the_dimstyle_table() { + let d = drawing(); + for v in [Version::R2007, Version::R2018] { + let text = dxf(&d, v); + let rec = record(&text, "Fill"); + let groups: Vec<(&str, &str)> = rec.chunks(2).filter_map(|p| Some((*p.first()?, *p.get(1)?))).collect(); + assert!(groups.contains(&("69", "2")), "{v:?}: DIMTFILL as group 69"); + let seventies: Vec<&str> = groups.iter().filter(|g| g.0 == "70").map(|g| g.1).collect(); + assert_eq!(seventies, ["0", "3"], "{v:?}: the flags, then DIMTFILLCLR"); + assert!(!rec.contains(&"DIMVARS"), "{v:?}: no xdata"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}"); + } + for v in [Version::R2000, Version::R2004] { + let text = dxf(&d, v); + let rec = record(&text, "Fill"); + assert!(!rec.chunks(2).any(|p| p[0] == "69"), "{v:?}: no group 69"); + assert!(rec.windows(4).any(|w| w == ["1070", "69", "1070", "2"]), "{v:?}: DIMTFILL in the xdata"); + assert!(rec.windows(4).any(|w| w == ["1070", "70", "1070", "3"]), "{v:?}: DIMTFILLCLR in the xdata"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Fill").unwrap(), &filled(), "{v:?}"); + } +} + +#[test] +fn record_flags_are_not_read_as_dimtfillclr() { + let text = dxf(&drawing(), Version::R2018); + // Flags 64 (referenced) and no DIMTFILLCLR group: the color stays ByBlock. + let edited = + text.replacen(" 2\r\nFill\r\n 70\r\n0\r\n", " 2\r\nFill\r\n 70\r\n64\r\n", 1).replacen(" 69\r\n2\r\n 70\r\n3\r\n", " 69\r\n2\r\n", 1); + assert!(edited.contains(" 2\r\nFill\r\n 70\r\n64\r\n"), "flags edited"); + assert!(edited.contains(" 69\r\n2\r\n") && !edited.contains(" 69\r\n2\r\n 70\r\n3\r\n"), "DIMTFILLCLR removed"); + let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap(); + let st = back.dim_style("Fill").unwrap(); + assert_eq!((st.text_fill, st.text_fill_color), (2, Color::ByBlock)); +} + +#[test] +fn reopened_dimension_keeps_its_background_mask() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Dimension(dm) if dm.block.is_some())), "read with its block"); + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Mask).count(), 1); +} + +#[test] +fn reopened_color_fill_is_drawn_once() { + let mut d = drawing(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { style: "Fill".into(), ..linear() })).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + // The block's SOLID in DIMTFILLCLR (color 3, green) is the background; no second one is added. + let green = list.prims.iter().filter(|p| p.kind == cadcraft_render::Kind::Tris && p.color == cadcraft_color::Rgb(0, 255, 0)).count(); + assert_eq!(green, 1); +} + +#[test] +fn reopened_inspection_frame_stays_over_the_background() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Fill".into(), text_fill: 1, ..DimStyle::default() }); + let mut dm = Dimension { style: "Fill".into(), ..linear() }; + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: "A".into(), rate: String::new() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let dm = back + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.clone()), + _ => None, + }) + .unwrap(); + assert!(dm.block.is_some(), "read with its block"); + let corners = cadcraft_render::dimension_in(&back, &dm).text_fill; + let (lo, hi) = corners.iter().fold((corners[0], corners[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + let inside = |q: &cadcraft_geom::Vec2| q.x >= lo.x - 1e-6 && q.x <= hi.x + 1e-6 && q.y >= lo.y - 1e-6 && q.y <= hi.y + 1e-6; + let list = cadcraft_render::build(&back, &Space::Model, &cadcraft_render::Options::default()); + let mask = list.prims.iter().position(|p| p.kind == cadcraft_render::Kind::Mask).unwrap(); + // The separator after the label, the frame's top and bottom: lines inside the background. + let marks: Vec = list + .prims + .iter() + .enumerate() + .filter(|(_, p)| p.kind == cadcraft_render::Kind::Polyline && list.points(p).len() == 2 && list.points(p).iter().all(inside)) + .map(|(i, _)| i) + .collect(); + assert!(marks.len() >= 3 && marks.iter().all(|i| *i > mask), "{marks:?} after the mask at {mask}"); +} + +/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5. +fn linear() -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + } +} + +#[test] +fn dimension_block_holds_the_text_background() { + let mut d = drawing(); + // An inspection frame (top, bottom and two angular ends: six segments) goes over the background. + let mut dm = Dimension { style: "Fill".into(), ..linear() }; + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() }); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let name = back + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ents: Vec<_> = back.block(&name).unwrap().entities.iter().cloned().collect(); + let solid = ents.iter().position(|e| matches!(e.kind, EntityKind::Solid(_)) && e.common.color == Color::Index(3)); + let mtext = ents.iter().position(|e| matches!(e.kind, EntityKind::MText(_))); + assert!(solid.is_some() && solid < mtext, "the background before the text: {solid:?}, {mtext:?}"); + let lines_after = ents.iter().skip(solid.unwrap_or(0)).filter(|e| matches!(e.kind, EntityKind::Line(_))).count(); + assert_eq!(lines_after, 6, "the frame after the background, the other lines before it"); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index e6382aec8..51e7f1965 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -13,6 +13,7 @@ mod dim_block_resave_roundtrip; mod dim_block_text; mod dim_marks_roundtrip; mod dimstyle_vars_roundtrip; +mod dimtfill_roundtrip; mod drafting_spacing; #[cfg(not(target_arch = "wasm32"))] mod dwg_header_vars; diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index c05a20014..9b7f613eb 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -4,7 +4,7 @@ //! fixed-length extension lines (DIMFXL/DIMFXLON), text placement (DIMTAD, DIMTVP, DIMJUST, //! DIMTIH/DIMTOH, DIMGAP, DIMTMOVE) and text formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND, //! DIMPOST, DIMTOL/DIMLIM with DIMTZIN and DIMTOLJ, DIMALT with DIMALTU/DIMALTZ/DIMALTRND, -//! DIMAZIN, DIMARCSYM). +//! DIMAZIN, DIMARCSYM) with its background (DIMTFILL). use cadcraft_doc::{DimKind, DimStyle, Dimension}; use cadcraft_fonts::{MTextParams, TextFont}; @@ -32,6 +32,12 @@ pub struct DimGeometry { pub line_roles: Vec, /// Filled triangles (three points each) in the dimension line colour. pub fills: Vec>, + /// DIMTFILL 1 or 2: the corners of the text's background, the text box widened by DIMGAP + /// on every side (empty without a fill or text). + pub text_fill: Vec, + /// Indices into `lines` of the marks drawn with the text, over `text_fill`: the arc length + /// symbol above the text (DIMARCSYM 1) and the DIMINSPECT frame. + pub text_marks: Vec, /// Text strokes (stroke font, fraction bars, underlines). pub text: Vec>, /// Text glyph outlines (TrueType), grouped per glyph. @@ -75,6 +81,13 @@ impl DimGeometry { self.line_roles.push(LineRole::Dim); } } + /// A dimension line that marks the text (see `text_marks`). + fn text_mark(&mut self, l: Vec) { + if l.len() >= 2 { + self.text_marks.push(self.lines.len()); + self.dim(l); + } + } fn ext(&mut self, l: Vec) { if l.len() >= 2 { self.lines.push(l); @@ -88,18 +101,21 @@ impl DimGeometry { } let mut lines = Vec::with_capacity(self.lines.len()); let mut roles = Vec::with_capacity(self.lines.len()); - let mut ext2 = Vec::new(); + let (mut ext2, mut marks) = (Vec::new(), Vec::new()); for (i, (l, rl)) in self.lines.iter().zip(&self.line_roles).enumerate() { let pieces = if role.is_none_or(|x| x == *rl) { cut_polyline(l, c, r) } else { vec![l.clone()] }; for p in pieces { if self.ext2_lines.contains(&i) { ext2.push(lines.len()); } + if self.text_marks.contains(&i) { + marks.push(lines.len()); + } lines.push(p); roles.push(*rl); } } - (self.lines, self.line_roles, self.ext2_lines) = (lines, roles, ext2); + (self.lines, self.line_roles, self.ext2_lines, self.text_marks) = (lines, roles, ext2, marks); } /// Extension line 1 or 2 (`second`). fn ext_n(&mut self, l: Option>, second: bool) { @@ -987,7 +1003,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35) }) .collect(); - g.dim(sym); + g.text_mark(sym); } } } @@ -1017,24 +1033,31 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font let mut left = -w / 2.0; if !ins.label.is_empty() { left += wl; - g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); + g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); left += gap2; } if !ins.rate.is_empty() { left += wv; - g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); + g.text_mark(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]); } - g.dim(vec![at(-hw, hh), at(hw, hh)]); - g.dim(vec![at(-hw, -hh), at(hw, -hh)]); + g.text_mark(vec![at(-hw, hh), at(hw, hh)]); + g.text_mark(vec![at(-hw, -hh), at(hw, -hh)]); for side in [-1.0, 1.0] { let ends: Vec = if ins.shape == 0 { (0..=12).map(|i| f64::from(i) * std::f64::consts::PI / 12.0).map(|a| at(side * (hw + hh * a.sin()), hh * a.cos())).collect() } else { vec![at(side * hw, hh), at(side * (hw + hh), 0.0), at(side * hw, -hh)] }; - g.dim(ends); + g.text_mark(ends); } } + // DIMTFILL: a background behind the text, the text box plus DIMGAP on every side. + if st.text_fill != 0 && !g.mtext.is_empty() { + let (w, h) = measure(&g.mtext); + let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp()); + let (hw, hh) = (w / 2.0 + gap, h / 2.0 + gap); + g.text_fill = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].into_iter().map(|(px, py)| text_mid + x * px + y * py).collect(); + } g.value = decode_value(&cadcraft_fonts::plain_mtext(&g.mtext)); if !g.mtext.is_empty() { let l = cadcraft_fonts::layout_mtext_with(&g.mtext, ¶ms(text_mid, text_angle)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 25ccf4101..cc7da1fa6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1125,10 +1125,39 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { && ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH { let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; + // DIMTFILL: the block's MTEXT carries no background mask here, so a background + // around each of the block's texts is drawn before the block's first text or first + // line of the text's marks, except for a text that a SOLID of the block covers. + let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() }; + let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0)); + let boxes: Vec> = if g.text_fill.is_empty() { + Vec::new() + } else { + let solids: Vec<&Entity> = + blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect(); + // A text's own box (no gap): a SOLID holding all its corners is its background. + let covered = + |be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p)))); + blk.entities + .iter() + .filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_))) + .take(MAX_FILL_TEXTS) + .filter(|be| !covered(be)) + .filter_map(|be| block_text_box(ctx.d, be, gap)) + .collect() + }; + let mut fill_pending = !boxes.is_empty(); for be in blk.entities.iter() { if !b.expand() { break; } + if fill_pending && starts_text_marks(be, &g) { + for corners in &boxes { + dim_text_fill(b, ctx, e, &style, corners); + } + fill_pending = false; + } entity(b, &sub, be); } } else { @@ -1455,23 +1484,126 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio }; let (dim_lw, ext_lw) = (weight(style.dim_lineweight), weight(style.ext_lineweight)); let (dim_lt, ext1_lt, ext2_lt) = (ltype(&style.dim_linetype), ltype(&style.ext_linetype1), ltype(&style.ext_linetype2)); - for (i, (l, role)) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))).enumerate() { - let (rgb, w, t) = match role { - LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()), - LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()), - LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()), - }; + let line_style = |i: usize| match g.line_roles.get(i).unwrap_or(&LineRole::Dim) { + LineRole::Ext if g.ext2_lines.contains(&i) => (ext_rgb, ext_lw, ext2_lt.as_ref()), + LineRole::Ext => (ext_rgb, ext_lw, ext1_lt.as_ref()), + LineRole::Dim => (dim_rgb, dim_lw, dim_lt.as_ref()), + }; + // The text's marks (arc length symbol, DIMINSPECT frame) come last, over the text background. + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| !g.text_marks.contains(i)) { + let (rgb, w, t) = line_style(i); stroke_with(b, ctx, rgb, w, t, ltscale, l); } for t in &g.fills { b.tris(ctx, dim_rgb, t); } + dim_text_fill(b, ctx, e, &style, &g.text_fill); + for (i, l) in g.lines.iter().enumerate().filter(|(i, _)| g.text_marks.contains(i)) { + let (rgb, w, t) = line_style(i); + stroke_with(b, ctx, rgb, w, t, ltscale, l); + } if b.opts.text { let sh = cadcraft_fonts::Shaped { strokes: g.text.clone(), glyphs: g.text_glyphs.clone(), width: 0.0, text: g.text_runs.clone() }; b.shaped(ctx, txt_rgb, lw, &sh); } } +/// DIMTFILL: a dimension's text background (`corners` from [`DimGeometry::text_fill`]) hides +/// what is drawn before it, in the drawing's background color (1) or DIMTFILLCLR (2). +fn dim_text_fill(b: &mut Builder, ctx: &Ctx, e: &Entity, style: &cadcraft_doc::DimStyle, corners: &[Vec2]) { + let [p0, p1, p2, p3] = corners else { return }; + if !b.opts.fill || style.text_fill == 0 { + return; + } + // DIMTFILLCLR ByBlock = the dimension's own color, as DIMCLRT. + let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); + let own = match e.common.color { + Color::ByLayer => layer_color, + Color::ByBlock => ctx.block_color, + c => c, + }; + let n = b.list.prims.len(); + b.tris(ctx, ink(style.text_fill_color, layer_color, own), &[*p0, *p1, *p2, *p0, *p2, *p3]); + if style.text_fill == 1 + && let Some(p) = b.list.prims.get_mut(n) + { + p.kind = Kind::Mask; + } +} + +/// A dimension block's text, or a line that is a segment of one of the text's marks in `g` +/// (as the `*D` block writer splits them): where the background goes. +fn starts_text_marks(be: &Entity, g: &DimGeometry) -> bool { + match &be.kind { + EntityKind::MText(_) | EntityKind::Text(_) => true, + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + g.text_marks + .iter() + .filter_map(|i| g.lines.get(*i)) + .any(|m| m.windows(2).any(|w| matches!(w, [p, q] if p.dist(a) < 1e-6 && q.dist(b) < 1e-6))) + } + _ => false, + } +} + +/// The background of a dimension block's MTEXT or TEXT: its box widened by `gap` on every side, +/// turned with the text (corners in order); `None` for other entities. +fn block_text_box(d: &Drawing, be: &Entity, gap: f64) -> Option> { + let t = match &be.kind { + EntityKind::MText(t) => t, + EntityKind::Text(t) => return text_box(d, t, gap), + _ => return None, + }; + let (bounds, at, rot) = (layout_mtext_entity(d, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }).bounds, t.insert.xy(), t.rotation); + if !(bounds.width().is_finite() && bounds.height().is_finite()) || bounds.width() <= 0.0 { + return None; + } + let (lo, hi) = (bounds.min - Vec2::new(gap, gap), bounds.max + Vec2::new(gap, gap)); + let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp()); + let turn = |p: Vec2| at + x * (p.x - at.x) + y * (p.y - at.y); + Some([lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)].into_iter().map(turn).collect()) +} + +/// The background of a TEXT as placed: the box of its strokes and glyph outlines along the text's +/// direction (the baseline from the insertion to the alignment point for Aligned and Fit), +/// widened by `gap` on every side. +fn text_box(d: &Drawing, t: &cadcraft_doc::Text, gap: f64) -> Option> { + let (sh, _) = place_text_entity(d, t, &t.value); + let rot = match (t.halign, t.align_pt) { + (cadcraft_doc::HAlign::Aligned | cadcraft_doc::HAlign::Fit, Some(p)) if p.xy().dist(t.insert.xy()) > 1e-12 => t.insert.xy().angle_to(p.xy()), + _ => t.rotation, + }; + let (x, y) = (Vec2::from_angle(rot), Vec2::from_angle(rot).perp()); + let pts: Vec = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).copied().collect(); + let first = pts.first()?; + let start = (first.dot(x), first.dot(y), first.dot(x), first.dot(y)); + let (s0, u0, s1, u1) = pts.iter().fold(start, |(s0, u0, s1, u1), p| (s0.min(p.dot(x)), u0.min(p.dot(y)), s1.max(p.dot(x)), u1.max(p.dot(y)))); + if !(s1 - s0 > 0.0 && (s0 + s1 + u0 + u1).is_finite()) { + return None; + } + let at = |s: f64, u: f64| x * s + y * u; + Some(vec![at(s0 - gap, u0 - gap), at(s1 + gap, u0 - gap), at(s1 + gap, u1 + gap), at(s0 - gap, u1 + gap)]) +} + +/// Most texts of one dimension block that get a DIMTFILL background, and most of its SOLIDs +/// checked for covering one. +const MAX_FILL_TEXTS: usize = 64; +const MAX_FILL_SOLIDS: usize = 256; + +/// Whether `be` is a SOLID that covers `p` (a text background the block holds already, as the +/// `*D` block of DIMTFILL 2 does). +fn solid_covers(be: &Entity, p: Vec2) -> bool { + let EntityKind::Solid(s) = &be.kind else { return false }; + // SOLID corners go 1, 2, 4, 3 around the outline. + let [c1, c2, c3, c4] = s.corners.map(|c| c.xy()); + let ring = [c1, c2, c4, c3]; + let next = [c2, c4, c3, c1]; + let sides: Vec = ring.iter().zip(next.iter()).map(|(a, b)| (*b - *a).cross(p - *a)).filter(|c| c.abs() > 1e-12).collect(); + !sides.is_empty() && (sides.iter().all(|c| *c > 0.0) || sides.iter().all(|c| *c < 0.0)) +} + mod table; pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of}; diff --git a/crates/render/tests/it/dim_text_fill.rs b/crates/render/tests/it/dim_text_fill.rs new file mode 100644 index 000000000..b90e1595d --- /dev/null +++ b/crates/render/tests/it/dim_text_fill.rs @@ -0,0 +1,268 @@ +//! DIMTFILL and DIMTFILLCLR: a dimension's text background hides what is drawn under the text, +//! in the drawing's background color (1) or in DIMTFILLCLR (2). + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Block, Circle, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, Handle, Line, MText, Space}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{DisplayList, Kind, Options, build, dimension_geometry}; + +/// A linear dimension from (0, 0) to (10, 0) with its line at y = 5, in style "Fill". +fn dimension() -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Fill".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + } +} + +/// DIMGAP 0.5 leaves room above the text: a line at y = 5.4 passes under the background but +/// clear of the text. +fn style(fill: u8, color: Color) -> DimStyle { + DimStyle { name: "Fill".into(), text_gap: 0.5, text_fill: fill, text_fill_color: color, ..DimStyle::default() } +} + +/// A line at y = 5.4 drawn first, then the dimension. +fn drawing(fill: u8, color: Color) -> Drawing { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(fill, color)); + let line = EntityKind::Line(Line { a: Vec3::new(-2.0, 5.4, 0.0), b: Vec3::new(12.0, 5.4, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension())).unwrap(); + d +} + +/// Dark pixels on the row of the line at y = 5.4, near the text (x 4.6 to 5.4) and far from it +/// (x -1.5 to -0.5), on white paper. +fn dark_on_line(list: &DisplayList) -> (usize, usize) { + let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 }; + let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() }; + let pm = render(list, &view, &o).unwrap(); + let (mut near, mut far) = (0, 0); + // World y = 5.4 is screen row 100 - 0.4 * 20 = 92; world x = 5 + (px - 200) / 20. + for y in 91..=93u32 { + for x in 0..400u32 { + let wx = 5.0 + (f64::from(x) + 0.5 - 200.0) / 20.0; + if pm.pixel(x, y).unwrap().red() > 128 { + continue; + } + if (4.6..5.4).contains(&wx) { + near += 1; + } else if (-1.5..-0.5).contains(&wx) { + far += 1; + } + } + } + (near, far) +} + +#[test] +fn text_fill_gives_the_text_a_background_box() { + let d = dimension(); + assert!(dimension_geometry(&d, &style(0, Color::ByBlock), 1.0).text_fill.is_empty(), "DIMTFILL 0: no background"); + let g = dimension_geometry(&d, &style(1, Color::ByBlock), 1.0); + let [a, b, c, e] = g.text_fill.as_slice() else { panic!("four corners: {:?}", g.text_fill) }; + // The box is centered on the text and reaches DIMGAP (0.5) past the text height (0.18). + let mid = (*a + *b + *c + *e) * 0.25; + assert!(mid.dist(g.text_pos) < 1e-9, "{mid:?} vs {:?}", g.text_pos); + assert!(((c.y - a.y) - (0.18 + 1.0)).abs() < 0.05, "height {}", c.y - a.y); + assert!(b.x - a.x > 1.0, "width {}", b.x - a.x); +} + +#[test] +fn text_fill_hides_what_is_drawn_under_the_text() { + let opts = Options::default(); + let (near, far) = dark_on_line(&build(&drawing(0, Color::ByBlock), &Space::Model, &opts)); + assert!(near > 0 && far > 0, "DIMTFILL 0: the line shows under the text ({near}, {far})"); + let list = build(&drawing(1, Color::ByBlock), &Space::Model, &opts); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1); + let (near, far) = dark_on_line(&list); + assert_eq!(near, 0, "DIMTFILL 1: the background hides the line under the text"); + assert!(far > 0, "the line shows away from the text"); +} + +#[test] +fn inspection_frame_is_drawn_over_the_background() { + // Polylines drawn after the background, for the dimension with and without an inspection + // frame (the text strokes come after it either way). + let after_mask = |dm: Dimension| { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).unwrap(); + list.prims[mask..].iter().filter(|p| p.kind == Kind::Polyline).count() + }; + let mut dm = dimension(); + // No label or rate: the same text, and a frame of top, bottom and two ends. + dm.marks.inspect = Some(cadcraft_doc::Inspection { shape: 1, label: String::new(), rate: String::new() }); + let frame = dimension_geometry(&dm, &style(1, Color::ByBlock), 1.0).text_marks.len(); + assert_eq!(frame, 4); + let (plain, inspected) = (after_mask(dimension()), after_mask(dm)); + assert_eq!(inspected, plain + frame, "the frame lines come after the background"); + // An arc length dimension's symbol over the text (DIMARCSYM 1) is a mark of the text too. + let arc = Dimension { + kind: DimKind::ArcLength, + defpt: Vec3::new(0.0, 6.0, 0.0), + p13: Vec3::new(5.0, 0.0, 0.0), + p14: Vec3::new(-5.0, 0.0, 0.0), + p15: Vec3::ZERO, + ..dimension() + }; + let st = DimStyle { arc_symbol: 1, ..style(1, Color::ByBlock) }; + let g = dimension_geometry(&arc, &st, 1.0); + let [sym] = g.text_marks.as_slice() else { panic!("one mark: {:?}", g.text_marks) }; + assert!(g.lines[*sym].len() == 13, "the 13-point arc over the text"); +} + +#[test] +fn a_dimension_read_with_its_block_keeps_the_block_and_gets_the_background() { + // The block (as read from a file) holds a circle the dimension's own geometry does not have, + // then the text, placed away from where CADCraft would put it. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let circle = EntityKind::Circle(Circle { center: Vec3::new(5.0, 5.0, 0.0), radius: 3.0 }); + blk.entities.push(Entity::new(Handle(1), circle)); + let text = MText { + insert: Vec3::new(20.0, 20.0, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: "10.0000".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background"); + let circle = list.prims.iter().position(|p| list.points(p).iter().any(|q| q.dist(Vec2::new(8.0, 5.0)) < 0.05)).expect("the block's circle"); + assert!(circle < mask, "the circle before the background, the text after it"); + // The background is around the block's text. + let pts = list.points(&list.prims[mask]); + let mid = pts.iter().fold(Vec2::ZERO, |s, p| s + *p) * (1.0 / pts.len() as f64); + assert!(mid.dist(Vec2::new(20.0, 20.0)) < 0.05, "{mid:?}"); +} + +#[test] +fn every_text_of_a_block_gets_a_background() { + // A measurement and a tolerance as two MTEXTs of the block. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + for (i, (y, s)) in [(20.0, "10.0000"), (22.0, "+0.1")].into_iter().enumerate() { + let text = MText { + insert: Vec3::new(20.0, y, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: s.into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(i as u64 + 1), EntityKind::MText(text))); + } + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 2); +} + +#[test] +fn a_block_that_holds_its_background_gets_no_second_one() { + // A block with a SOLID, then its text at (20, 20), away from where CADCraft would put it: the + // number of masks drawn. + let masks = |solid: [(f64, f64); 4]| { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let solid = cadcraft_doc::Solid { corners: solid.map(|(x, y)| Vec3::new(x, y, 0.0)) }; + blk.entities.push(Entity::new(Handle(1), EntityKind::Solid(solid))); + let text = MText { + insert: Vec3::new(20.0, 20.0, 0.0), + height: 0.18, + width: 0.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: "10.0000".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }; + blk.entities.push(Entity::new(Handle(2), EntityKind::MText(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + list.prims.iter().filter(|p| p.kind == Kind::Mask).count() + }; + assert_eq!(masks([(19.0, 19.5), (21.0, 19.5), (19.0, 20.5), (21.0, 20.5)]), 0, "the SOLID covers the text"); + // A small SOLID (an arrowhead) over the middle of the text only: the background is drawn. + assert_eq!(masks([(19.9, 19.95), (20.1, 20.0), (19.9, 20.05), (19.9, 20.05)]), 1, "the SOLID covers part of the text"); +} + +#[test] +fn a_block_with_turned_text_gets_a_turned_background() { + // A block whose text is a TEXT turned 90 degrees, at (20, 20): the background is taller than + // wide and holds every point of the text. + let mut d = Drawing::new_imperial(); + d.dim_styles.push(style(1, Color::ByBlock)); + let mut blk = Block::new("*D1"); + let text = cadcraft_doc::Text { + insert: Vec3::new(20.0, 20.0, 0.0), + align_pt: None, + height: 0.5, + value: "10.00".into(), + rotation: std::f64::consts::FRAC_PI_2, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + blk.entities.push(Entity::new(Handle(1), EntityKind::Text(text))); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(Dimension { block: Some("*D1".into()), ..dimension() })).unwrap(); + let list = build(&d, &Space::Model, &Options::default()); + let mask = list.prims.iter().position(|p| p.kind == Kind::Mask).expect("a background"); + let quad = list.points(&list.prims[mask]); + let (lo, hi) = quad.iter().fold((quad[0], quad[0]), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + assert!(hi.y - lo.y > 1.5 * (hi.x - lo.x), "turned: {lo:?} {hi:?}"); + let text: Vec = list.prims[mask + 1..].iter().flat_map(|p| list.points(p).to_vec()).collect(); + assert!(!text.is_empty() && text.iter().all(|p| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y), "the text inside {lo:?} {hi:?}"); +} + +#[test] +fn text_fill_2_paints_dimtfillclr() { + let list = build(&drawing(2, Color::Index(1)), &Space::Model, &Options::default()); + assert_eq!(list.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 0); + let red: Vec<_> = list.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)).collect(); + assert_eq!(red.len(), 1, "one red background"); + // Over the line, next to the text: the background is drawn after the line. + let view = View { center: Vec2::new(5.0, 5.0), scale: 20.0, width: 400, height: 200 }; + let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() }; + let pm = render(&list, &view, &o).unwrap(); + let c = pm.pixel(194, 92).unwrap(); + assert_eq!((c.red(), c.green(), c.blue()), (255, 0, 0)); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7469173d0..5f0005496 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -2,6 +2,7 @@ mod aci7_display; mod annotative_viewport; mod dim_angular_centre; mod dim_post_tolerance; +mod dim_text_fill; mod dim_text_format; mod hatch_fill; mod linetype_text; diff --git a/docs/gaps.md b/docs/gaps.md index 44ed6f22e..0c2863a53 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (DIMTFILL and DIMTFILLCLR: F7 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), and remove or update a row in the same PR @@ -29,7 +29,7 @@ do them first. | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | -| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (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 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed | From 6dc336160d5ab9db85ecca6c005919dd51f4574e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:22:26 -0700 Subject: [PATCH 18/91] SETVAR refuses DIM* values the dimension style refuses SETVAR wrote a DIM* variable to the drawing header without checking it, so values such as DIMTMOVE 3, DIMATFIT 7 or DIMLWD 500 were accepted. New dimensions then ignored them, because the style override goes through DimStyle::apply_fields, which refuses them. SETVAR now runs the value through apply_fields on a copy of the current dimension style, as DIMSTYLE and DIMOVERRIDE do, and reports an invalid value instead of writing it. --- crates/engine/src/sysvars.rs | 7 +++++++ crates/engine/tests/it/dim_vars.rs | 31 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 08f684655..042185772 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -379,6 +379,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { return r; } let d = s.doc_mut()?; + // A dimension variable takes only the values its style field takes, as in DIMSTYLE and DIMOVERRIDE. + if let Some((_, field)) = cadcraft_doc::DIMVARS.iter().find(|(var, _)| *var == n) { + let mut st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default(); + if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() { + return Err(bad()); + } + } let val = match v { Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)), Value::Number(x) => cadcraft_doc::HVal::Real(x.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?), diff --git a/crates/engine/tests/it/dim_vars.rs b/crates/engine/tests/it/dim_vars.rs index 018485eea..4c584967d 100644 --- a/crates/engine/tests/it/dim_vars.rs +++ b/crates/engine/tests/it/dim_vars.rs @@ -196,6 +196,37 @@ fn text_move_settings() { assert!((dm.defpt.y - (12.0 - 0.18)).abs() < 1e-9, "{:?}", dm.defpt); } +#[test] +fn setvar_refuses_the_values_the_style_refuses() { + let mut s = Session::new(); + set(&mut s, "DIMTMOVE", json!(1)); + let get = |s: &mut Session, name: &str| s.execute("getvar", &json!({ "name": name })).unwrap()["value"].clone(); + for (name, v) in [ + ("DIMTMOVE", json!(3)), + ("DIMATFIT", json!(7)), + ("DIMLWD", json!(500)), + ("DIMJOGANG", json!(120)), + ("DIMFXL", json!(-1)), + ("DIMTXT", json!(0)), + ("DIMDEC", json!(300)), + ("DIMCLRT", json!("no such color")), + ] { + let before = get(&mut s, name); + assert!(s.execute("setvar", &json!({ "name": name, "value": v })).is_err(), "{name} {v}"); + assert_eq!(get(&mut s, name), before, "{name} keeps its value"); + // DIMSTYLE and DIMOVERRIDE refuse the same value. + let mut st = DimStyle::default(); + assert_eq!(st.apply_fields(&serde_json::Map::from_iter([(name.to_string(), v)])).len(), 1, "{name}"); + } + assert_eq!(s.doc().unwrap().dim_overrides().get("textMove"), Some(&json!(1))); + // Values in range still go in, typed as text too. + set(&mut s, "DIMTMOVE", json!("2")); + set(&mut s, "DIMCLRT", json!(3)); + let o = s.doc().unwrap().dim_overrides(); + assert_eq!(o.get("textMove"), Some(&json!(2))); + assert_eq!(o.get("textColor"), serde_json::to_value(cadcraft_engine::doc::color::Color::Index(3)).ok().as_ref(), "{o:?}"); +} + #[test] fn tolerance_justification_angular_zeros_and_arc_symbol() { let mut s = Session::new(); From 1271b06b0212681fd878a9e75dc6942e4ac95bdf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 16:19:17 -0700 Subject: [PATCH 19/91] Refuse negative values for whole-number DIM* variables DimStyle::apply_fields rounded a value for a whole-number field such as DIMDEC or DIMTMOVE and clamped it to 0..255, so DIMDEC -1 became 0 and DIMALTD "300" became 255. DIMSTYLE, DIMOVERRIDE and the SETVAR overrides that new dimensions take all accepted these values. A value below 0 or one that rounds above 255 is now refused, as an out-of-range value is. DIMADEC still takes -1. A lineweight given as -1, -2 or -3 (ByLayer, ByBlock, Default) is no longer read as 0 when the style's lineweight is positive, and a lineweight above 211 given as text is refused instead of read as 211. --- crates/doc/src/tables.rs | 13 +++++- crates/engine/tests/it/dim_vars.rs | 63 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 69badebb0..68493077c 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -476,10 +476,19 @@ impl DimStyle { let coerced = match (&old, &coerced) { // Lineweights (i16): whole hundredths of a mm or -1/-2/-3. (Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => { - serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64) + serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64) } + (Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced, + // A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0. (Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => { - serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64) + let min = if field == "angularDecimals" { -1.0 } else { 0.0 }; + match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) { + Some(x) => serde_json::json!(x.max(0.0) as u64), + None => { + rejected.push(k.clone()); + continue; + } + } } _ => coerced, }; diff --git a/crates/engine/tests/it/dim_vars.rs b/crates/engine/tests/it/dim_vars.rs index 018485eea..5ae7813ce 100644 --- a/crates/engine/tests/it/dim_vars.rs +++ b/crates/engine/tests/it/dim_vars.rs @@ -262,6 +262,69 @@ fn linetypes_and_lineweights_of_dimension_and_extension_lines() { assert!(lws.contains(&0.5) && lws.contains(&0.13), "{lws:?}"); } +fn ignored(r: &Value) -> Vec<&str> { + r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default() +} + +#[test] +fn dimstyle_and_dimoverride_refuse_negative_whole_numbers() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "Standard", "DIMTMOVE": 2, "DIMLWD": 50 })).unwrap(); + let r = s + .execute( + "dimstyle", + &json!({ + "name": "Standard", + "DIMDEC": -1, + "DIMTMOVE": -1, + "DIMLUNIT": "-1", + "DIMTDEC": -0.6, + "DIMADEC": -2, + "DIMALTD": "300", + "DIMLWE": "300", + }), + ) + .unwrap(); + let ign = ignored(&r); + for k in ["DIMDEC", "DIMTMOVE", "DIMLUNIT", "DIMTDEC", "DIMADEC", "DIMALTD", "DIMLWE"] { + assert!(ign.contains(&k), "{k}: {r}"); + } + let st = s.doc().unwrap().dim_style("Standard").unwrap().clone(); + let def = DimStyle::default(); + assert_eq!((st.decimals, st.text_move, st.linear_unit, st.tol_decimals), (def.decimals, 2, def.linear_unit, def.tol_decimals)); + assert_eq!((st.angular_decimals, st.alt_decimals, st.ext_lineweight), (def.angular_decimals, def.alt_decimals, def.ext_lineweight)); + // DIMADEC -1 is a documented value (stored as 0); ByLayer lineweights stay -1. + let r = s.execute("dimstyle", &json!({ "name": "Standard", "DIMADEC": -1, "DIMLWD": "ByLayer" })).unwrap(); + assert!(r.get("ignored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().dim_style("Standard").unwrap().dim_lineweight, -1); + + // DIMOVERRIDE keeps the valid value and leaves the negative one out. + linear(&mut s, 10.0); + let h = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("dimoverride", &json!({ "handles": [h], "DIMDEC": -1, "DIMTMOVE": 1 })).unwrap(); + let (dm, _) = last_dim(&s); + assert!(!dm.overrides.contains_key("decimals"), "{:?}", dm.overrides); + assert_eq!(dm.overrides.get("textMove"), Some(&json!(1))); + assert!(s.execute("dimoverride", &json!({ "handles": [h], "DIMZIN": -3 })).is_err()); +} + +#[test] +fn setvar_negative_whole_numbers_do_not_reach_new_dimensions() { + let mut s = Session::new(); + for (name, v) in [("DIMDEC", json!(-1)), ("DIMLUNIT", json!("-1")), ("DIMTDEC", json!(-0.6))] { + // The header may hold the value; new dimensions keep the style's own. + let _ = s.execute("setvar", &json!({ "name": name, "value": v })); + } + let o = s.doc().unwrap().dim_overrides(); + for f in ["decimals", "linearUnit", "tolDecimals"] { + assert!(!o.contains_key(f), "{f}: {o:?}"); + } + linear(&mut s, 10.0); + let (dm, g) = last_dim(&s); + assert_eq!(g.value, "10.0000"); + assert!(dm.overrides.is_empty(), "{:?}", dm.overrides); +} + #[test] fn leaders_use_the_leader_arrow() { let mut s = Session::new(); From b90165666917ec3ac6c308c2ea1c55bb6ea8317d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:23:39 -0400 Subject: [PATCH 20/91] Panels: use craft-ui's shared docking (#2093) * Pin craft-libs at 420d21a * Pin craft-libs at 16fb3d4 * Use craft-ui's shared docking workspace and ui.dock commands Replace the local Workspace, its validation, apply, visibility and the ui.dock parser with craft_ui::docking::Workspace and DockOperation. CADCraft keeps its panels, the drawing view's rules, the default layout and the workspace file. Add tests for loading a v1 workspace file and for dragging a panel group by its bar. * Pin craft-libs at 6e7f285 * Docking: reveal panels through craft-ui's set_visible, which reopens groups and places new panels itself * Docking: palette switches regroup a panel whose recorded anchor is hidden instead of opening it alone --- Cargo.lock | 16 +- Cargo.toml | 12 +- apps/cadcraft/src/workspace.rs | 49 ++- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/docking.rs | 349 +++++++------------ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/tests/it/docking_workflows.rs | 95 ++++- crates/ui-egui/tests/quickcalc_toggle.rs | 6 +- docs/control-protocol.md | 6 +- 9 files changed, 270 insertions(+), 267 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48e90a2b1..9dc213ab4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,12 +959,12 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-syntax", "krilla", @@ -974,9 +974,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] @@ -1835,7 +1839,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-ccitt", ] @@ -1849,7 +1853,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2195,7 +2199,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index d3ea9a09a..54b1bf711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } # foundation thiserror = "2" @@ -53,7 +53,7 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } egui = "0.36" egui_kittest = "0.36" eframe = { version = "0.36", default-features = false } @@ -62,9 +62,9 @@ rfd = "0.17" arboard = { version = "3.6", default-features = false } [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index e2a429d94..f4e34d4fb 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -32,7 +32,7 @@ fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Op } fn snapshot(ui: &UiState) -> Result { - ui.docking.validate()?; + cadcraft_ui_egui::docking::validate(&ui.docking)?; if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { return Err("toolsetTab must be Drafting or Modeling".into()); } @@ -85,7 +85,7 @@ fn read(path: &Path) -> Result, String> { let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; if let Some(docking) = docking && let Ok(docking) = serde_json::from_value::(docking) - && docking.validate().is_ok() + && cadcraft_ui_egui::docking::validate(&docking).is_ok() { ui.docking = docking; } @@ -236,7 +236,7 @@ mod tests { #[test] fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { - use cadcraft_ui_egui::docking::Panel; + use cadcraft_ui_egui::docking::{Panel, permissions}; let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); @@ -244,19 +244,58 @@ mod tests { let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); ui.docking = app.ui.docking; - ui.docking.set_visible(Panel::ToolSets, false).unwrap(); + ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap(); ui.show_toolsets = false; store.save_if_changed(&ui).unwrap(); let (_, reloaded, error) = Workspace::at(Some(path)); assert!(error.is_none()); let mut reloaded = reloaded.unwrap(); assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); - reloaded.docking.set_visible(Panel::ToolSets, true).unwrap(); + reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap(); assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); fs::remove_dir_all(directory).unwrap(); } + #[test] + fn version_one_docking_file_still_loads() { + use cadcraft_ui_egui::docking::{Panel, reveal_panel}; + // Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a + // floating window as one `{panels, active, rect}` group, Tool Sets closed. + let file = r#"{"version":1,"workspace":{ + "docking":{ + "layout":{ + "root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0}, + "first":{"Tabs":{"panels":["canvas"],"active":0}}, + "second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}}, + "floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]}, + "hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"}, + "size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]}, + "showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#; + let directory = temp_dir(); + let path = directory.join("workspace.json"); + fs::write(&path, file).unwrap(); + let (_, restored, error) = Workspace::at(Some(path)); + assert!(error.is_none(), "{error:?}"); + let mut ui = restored.unwrap(); + assert_eq!(ui.toolset_tab, "Modeling"); + let docking = &ui.docking; + assert_eq!(docking.layout.floating.len(), 1); + let window = &docking.layout.floating[0]; + assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]); + assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}})); + assert_eq!( + serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"], + json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}) + ); + assert!(!docking.layout.contains(&Panel::ToolSets)); + reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap(); + let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(tools.anchor, Some(Panel::Canvas)); + assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}])); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn malformed_oversized_and_newer_state_are_reported_and_preserved() { let directory = temp_dir(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bc6d23ef5..b66c0919c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -452,7 +452,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } match serde_json::from_value::(cur) { Ok(u) => { - if let Err(error) = u.docking.validate() { + if let Err(error) = crate::docking::validate(&u.docking) { return err(error); } app.ui = u; diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index c44f8d1cf..6b9cedbe9 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock, + Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -40,25 +43,13 @@ impl Panel { Self::ExternalRefs => "External References", } } - fn default_side(self) -> Zone { - match self { - Self::Canvas => Zone::Right, - Self::ToolSets => Zone::Left, - Self::QuickCalc => Zone::Right, - Self::Layers => Zone::Right, - Self::Properties => Zone::Right, - Self::ExternalRefs => Zone::Right, - } - } /// Palettes outside the default arrangement, opened on request (QUICKCALC). pub const ON_DEMAND: &[Self] = &[Self::QuickCalc]; - fn parse(id: &str) -> Result { - Self::ALL.iter().chain(Self::ON_DEMAND).copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } -fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +/// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it. +pub fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -69,230 +60,137 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved panel arrangement: craft-ui's workspace over CADCraft's panels. +pub type Workspace = craft_ui::docking::Workspace; + +/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right. +pub fn default_workspace() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(220.0), + tabs(Panel::ToolSets), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Stack { + entries: vec![ + StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, + StackEntry { panel: Panel::Properties, open: true, height: None }, + ], + }, + ), + )), + floating: Vec::new(), + }) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(220.0), - tabs(Panel::ToolSets), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), - tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), +/// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, +/// group or collapse it, or restore a palette over it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(|error| error.to_string())?; + if !workspace.layout.contains(&Panel::Canvas) { + return Err("The panel layout must contain its document view".into()); + } + if workspace.layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => { + return Err("The document view cannot be in a dock".into()); + } + _ => {} } } + let over_canvas = |location: &Location| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }; + if workspace.hidden.iter().any(|(_, location)| over_canvas(location)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() + Panel::ON_DEMAND.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } +/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place +/// for), keeping the drawing view's rules. +pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + let mut next = workspace.clone(); + next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?; + validate(&next)?; + *workspace = next; + Ok(()) +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) +/// Show or hide a panel for the Tool Sets and palette switches. Showing a panel whose recorded +/// anchor is shown restores it there without changing which tab is selected or which accordion +/// entries are open. Otherwise it goes through [`reveal_panel`], which regroups it with hidden +/// panels it was beside, and selects it. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); } - - /// Explicit opens select the requested tab and expand a collapsed stack entry. - pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> { - let mut next = self.clone(); - next.set_visible(panel, true)?; - next.apply(Action::Activate { panel })?; - *self = next; - Ok(()) + if !visible { + return workspace.set_visible(panel, false, None, permissions).map_err(|error| error.to_string()); } - - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - // Recreate the last surviving member first. Restoring a member also restores any - // hidden anchor it needs, so either close order retains the original tab order. - // Remove this member before recursion to bound malformed/cyclic saved anchors. + let mut next = workspace.clone(); + let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + // A direct restore against a hidden anchor would open a window of its own. + let direct = location.filter(|location| location.anchor.is_none_or(|anchor| next.layout.contains(&anchor))); + if direct.is_some_and(|location| next.layout.restore(panel, &location).is_ok()) { next.hidden.retain(|(id, _)| *id != panel); - if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor) - && !next.layout.contains(&anchor) - && next.hidden.iter().any(|(id, _)| *id == anchor) - { - next.set_visible(anchor, true)?; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) + validate(&next)?; + *workspace = next; + return Ok(()); } + reveal_panel(workspace, panel) +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); +/// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar. +fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let operation = if let Some(action) = params.get("action") { + UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? } + } else { + UiDock::Operation(serde_json::from_value(params.clone()).map_err(|error| error.to_string())?) + }; + let mut next = workspace.clone(); + match operation { + UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel }) if !next.layout.contains(&panel) => { + reveal_panel(&mut next, panel)?; } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); + operation => { + // Every ui.dock operation carries its own rect, so the float space is never consulted. + let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))); + DockCommand::UiDock(operation).apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?; } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal_panel(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) } + validate(&next)?; + *workspace = next; + Ok(()) } fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { - workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?; + set_visible(workspace, Panel::ToolSets, app.ui.show_toolsets)?; if !app.ui.show_palettes { - workspace.set_visible(Panel::Layers, false)?; - workspace.set_visible(Panel::Properties, false)?; + set_visible(workspace, Panel::Layers, false)?; + set_visible(workspace, Panel::Properties, false)?; } else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) { // Reopen the last surviving accordion entry before restoring its earlier sibling. - workspace.set_visible(Panel::Properties, true)?; - workspace.set_visible(Panel::Layers, true)?; + set_visible(workspace, Panel::Properties, true)?; + set_visible(workspace, Panel::Layers, true)?; } Ok(()) } @@ -305,10 +203,10 @@ fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) { pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { let mut workspace = app.ui.docking.clone(); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); } else { sync(&mut workspace, app)?; - workspace.command(params)?; + apply_command(&mut workspace, params)?; } publish_visibility(app, &workspace); app.ui.docking = workspace; @@ -318,9 +216,9 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result(ui.ctx(), egui::Id::new("cadcraft-docking")); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 087e5cbf8..a627baf08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,7 +156,7 @@ impl SaveFormat { impl Default for UiState { fn default() -> Self { UiState { - docking: docking::Workspace::default(), + docking: docking::default_workspace(), interface_language: i18n::Preference::default(), show_toolsets: true, diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 1e3ee118c..1cffab495 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -52,19 +52,19 @@ fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { assert_eq!(location.anchor, Some(Panel::ToolSets)); harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Float panel").click(); + harness.get_by_label("Float Panel").click(); harness.run_steps(5); - let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect; + let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect; harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); harness.run_steps(3); - harness.get_by_label("Close panel").click(); + harness.get_by_label("Close Panel").click(); harness.run_steps(4); assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers)); harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); harness.run_steps(3); harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); harness.run_steps(4); - assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect); + assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::Layers)).unwrap().rect, rect); let saved = serde_json::to_string(&harness.state().ui).unwrap(); let expected = serde_json::to_value(&harness.state().ui.docking).unwrap(); let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0); @@ -88,6 +88,13 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}), json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}), json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}), + json!({"operation":"float", "panel":"layers", "rect":[1e39,0,300,200]}), + json!({"operation":"resizeStack", "panel":"layers", "height":1e39}), + json!({"operation":"resizeSplit", "path":[true,true,true,true,true,true,true,true], "size":{"Ratio":0.5}}), + json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}), + json!({"operation":"spin", "panel":"layers"}), + json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}), + json!(null), ] { assert!(harness.state_mut().run("ui.dock", params).is_err()); assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); @@ -171,28 +178,47 @@ fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() #[test] fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() { + use cadcraft_ui_egui::docking::{default_workspace, permissions, reveal_panel}; use craft_ui::docking::{Action, Placement}; for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] { - let mut workspace = cadcraft_ui_egui::docking::Workspace::default(); + let mut workspace = default_workspace(); let rect = [50.0, 60.0, 320.0, 400.0]; - workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap(); + workspace.apply(Action::Float { panel: Panel::Layers, rect }, permissions).unwrap(); workspace - .apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }) + .apply( + Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }, + permissions, + ) .unwrap(); - let expected = workspace.layout.floating[0].panels.clone(); + let expected: Vec = workspace.layout.floating[0].panels().copied().collect(); for panel in order { - workspace.set_visible(panel, false).unwrap(); + workspace.set_visible(panel, false, None, permissions).unwrap(); } let saved = serde_json::to_string(&workspace).unwrap(); workspace = serde_json::from_str(&saved).unwrap(); - workspace.reveal_panel(Panel::Properties).unwrap(); - workspace.reveal_panel(Panel::Layers).unwrap(); + reveal_panel(&mut workspace, Panel::Properties).unwrap(); + reveal_panel(&mut workspace, Panel::Layers).unwrap(); assert_eq!(workspace.layout.floating.len(), 1); - assert_eq!(workspace.layout.floating[0].panels, expected); + assert_eq!(workspace.layout.floating[0].panels().copied().collect::>(), expected); assert_eq!(workspace.layout.floating[0].rect, rect); } } +#[test] +fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let floating = &h.state().ui.docking.layout.floating; + assert_eq!(floating.len(), 1, "{floating:?}"); + assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}"); +} + #[test] fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { let mut harness = harness(None, 1000.0, 1.0); @@ -206,8 +232,7 @@ fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { harness.run_steps(3); let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap(); assert_eq!(location.anchor, Some(Panel::Layers)); - let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert_eq!(harness.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); } #[test] @@ -266,8 +291,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap(); h.run_steps(3); type_command(&mut h, "PROPERTIES"); - let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); - assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert!(h.state().ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Properties))); + assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Properties), craft_ui::docking::PanelState::Front); assert_eq!(h.state().session.doc().unwrap(), &drawing); } @@ -278,7 +303,7 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); type_command(&mut h, "ui.dock.reset"); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert_eq!(h.state().ui.docking.layout, expected.layout); assert_eq!(h.state().ui.docking.hidden, expected.hidden); assert_eq!(h.state().session.doc().unwrap(), &drawing); @@ -319,7 +344,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { h.event(Event::Text("ui.dock.reset".into())); h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(1); - let expected = cadcraft_ui_egui::docking::Workspace::default(); + let expected = cadcraft_ui_egui::docking::default_workspace(); assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command"); assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree"); assert_eq!(h.state().ui.docking.hidden, expected.hidden); @@ -359,3 +384,37 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { h.run_steps(4); assert!(h.state().ui.show_toolsets); } + +#[test] +fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { + use craft_ui::docking::{DockArea, Hit, Node}; + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); + h.run_steps(4); + let bar = |h: &Harness<'_, CadApp>| { + DockArea::::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking")) + .into_iter() + .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) + .map(|(_, rect)| rect) + }; + let from = bar(&h).expect("the Tool Sets group draws a drag bar").center(); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + let to = egui::pos2(drawing.right() - 10.0, drawing.center().y); + let document = h.state().session.doc().unwrap().clone(); + drag(&mut h, from, to); + let layout = &h.state().ui.docking.layout; + assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group"); + // The drawing's frame splits: the drawing on the left, the whole group on the right. + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + let mut beside = false; + while let Some(node) = nodes.pop() { + if let Node::Split { first, second, .. } = node { + beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. }) + if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]); + nodes.push(first); + nodes.push(second); + } + } + assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root); + assert_eq!(h.state().session.doc().unwrap(), &document); +} diff --git a/crates/ui-egui/tests/quickcalc_toggle.rs b/crates/ui-egui/tests/quickcalc_toggle.rs index 7f643c90d..9b6bec6d1 100644 --- a/crates/ui-egui/tests/quickcalc_toggle.rs +++ b/crates/ui-egui/tests/quickcalc_toggle.rs @@ -43,7 +43,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert!(h.state().ui.docking.layout.contains(&Panel::QuickCalc)); - let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect; + let floating = h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect; expression(&h).click(); h.run_steps(3); expression(&h).type_text("2+3"); @@ -55,7 +55,7 @@ fn platform_shortcut_toggles_with_expression_focus_and_preserves_drafting() { h.key_press_modifiers(modifiers, Key::Num8); h.run_steps(5); assert_eq!(h.get(by().role(Role::TextInput).value("2+3")).value().as_deref(), Some("2+3")); - assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::QuickCalc)).unwrap().rect, floating); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|group| group.contains(&Panel::QuickCalc)).unwrap().rect, floating); assert_eq!(h.state().session.running.as_ref().unwrap().id, "line"); assert_eq!(h.state().cmd.buffer, "12,34"); assert_eq!(h.state().session.doc().unwrap(), &drawing); @@ -131,7 +131,7 @@ fn explicit_opens_still_reveal_and_calculations_do_not_open_the_palette() { app.run("ui.dock", json!({"operation":"activate", "panel":"toolSets"})).unwrap(); app.run("ui.panel.quickcalc", Value::Null).unwrap(); let mut workspace = app.ui.docking.clone(); - workspace.reveal_panel(Panel::QuickCalc).unwrap(); + cadcraft_ui_egui::docking::reveal_panel(&mut workspace, Panel::QuickCalc).unwrap(); assert_eq!(app.ui.docking.layout, workspace.layout, "explicit open must activate an inactive tab"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 424d7cd60..63e565eb6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -134,7 +134,7 @@ Tool Sets, Layers, and Properties can be regrouped, split, floated within the ap Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`. -`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`, default `center`). Center and tab group with the target; side zones split beside it; above and below add a group in a floating window. `open` and `activate` show a hidden panel at its last place and select it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. The QuickCalc palette (`quickCalc`) is not part of the default arrangement. Typed `QUICKCALC`/`QC` and `ui.panel.quickcalc` explicitly open and activate it: it floats the first time and later @@ -150,9 +150,9 @@ Advanced `ui.dock` operations use the same validated transaction as pointer gest `moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and `size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), `setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. +A center or tab `move` can include `before` (panel ID or null to append) for tab order. The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel +this is also the form emitted by the renderer, and it takes every action of craft-ui's docking, group, column and window moves included. Protected-canvas and app-specific panel rules apply equally to both forms. ## Command-line replies From 32be8a655ef27c88f1497153ae738c2ef07c5980 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:43:16 -0700 Subject: [PATCH 21/91] COMPAREEXPORT: a dimension block with another generated name does not make PART differ A named block such as PART was compared with the other drawing's by the blocks it draws, including a dimension's block. That block's generated name (`*D1` here, `*D3` there) was part of its signature, so the same PART counted as different and was copied as PART_2 with a copy of the dimension block. The comparison now leaves out an anonymous block's name and compares its contents. --- crates/engine/src/cmd/file/compare.rs | 12 +++++-- crates/engine/tests/compare_export.rs | 50 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index bccd7b2d8..d5a9d3ebc 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -141,7 +141,8 @@ struct Signer<'a> { work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay /// flags and xref file, with a relative xref path taken from the folder given, and the - /// signatures of a multileader and a dimension cover their blocks'. + /// signatures of a multileader and a dimension cover their blocks'. An anonymous block's + /// signature then leaves out its name. frames: Option>, } @@ -190,9 +191,14 @@ impl<'a> Signer<'a> { return *v; } let mut h = std::collections::hash_map::DefaultHasher::new(); - key.hash(&mut h); + let block = self.d.block(name); + // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the + // same block can have another generated name in another drawing. + if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { + key.hash(&mut h); + } if depth < MAX_BLOCK_DEPTH - && let Some(b) = self.d.block(name) + && let Some(b) = block { if let Some(folder) = self.frames { let mut xref = b.xref_path.clone(); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 8a2b0aa47..2aeab751b 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -352,6 +352,56 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( assert_eq!(ends(&s, "*D2"), vec![5.0]); } +#[test] +fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds the same dimension in both drawings; the block it draws holds the same line but + // is named `*D1` here and `*D3` in the other drawing. + let blocks = |drawn_name: &str, first: u64| { + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some(drawn_name.into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); + let mut drawn = Block::new(drawn_name); + drawn.entities.push(Entity::new(Handle(first + 1), line(1.0))); + [part, drawn] + }; + let mut s = Session::new(); + for b in blocks("*D1", 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks("*D3", 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + assert!(d.block("PART_2").is_none(), "PART draws the same thing in both drawings"); + assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 5685085add9a074a9fc18c6149544697ce4d39f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:52:56 -0700 Subject: [PATCH 22/91] COMPAREEXPORT: a block whose objects are hidden or thicker in the other drawing is copied as PART_2 The comparison of a named block both drawings define counted each object's geometry, layer, color, linetype, lineweight and linetype scale. A block whose line was hidden, more transparent, thicker or extruded another way in the other drawing counted as the same, so the other drawing's references drew with this drawing's definition. These properties now count as well. --- crates/engine/src/cmd/file/compare.rs | 9 +++++++-- crates/engine/tests/compare_export.rs | 28 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index d5a9d3ebc..58629f025 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -141,8 +141,9 @@ struct Signer<'a> { work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay /// flags and xref file, with a relative xref path taken from the folder given, and the - /// signatures of a multileader and a dimension cover their blocks'. An anonymous block's - /// signature then leaves out its name. + /// signatures of a multileader and a dimension cover their blocks'. Each object in a block + /// then also counts with its visibility, transparency, thickness and extrusion, and an + /// anonymous block's signature leaves out its name. frames: Option>, } @@ -214,6 +215,10 @@ impl<'a> Signer<'a> { self.work += 1; self.geometry(&e.kind, depth + 1).hash(&mut h); properties(e).hash(&mut h); + if self.frames.is_some() { + let c = &e.common; + format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h); + } } } let v = h.finish(); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 2aeab751b..a38d08482 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -402,6 +402,34 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); } +#[test] +fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() { + let run = |theirs: Common| { + let part = |common: Common, first: u64| { + let mut part = Block::new("PART"); + let mut e = Entity::new(Handle(first), line(1.0)); + e.common = common; + part.entities.push(e); + part + }; + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + s.doc().unwrap().block("PART_2").is_some() + }; + assert!(run(Common { visible: false, ..Common::default() }), "a hidden line"); + assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness"); + assert!(!run(Common::default()), "the same line"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 20087b4b5f78715e6860e7534d7bf75e7ec093d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:16:04 -0700 Subject: [PATCH 23/91] COMPAREEXPORT: multiline attributes take the comparison color too A multiline attribute is drawn from its own multiline text, so an inline color code in it overrode the comparison color set on the attribute: an unchanged attribute could stay red, or a removed one green. The export now takes the inline colors out of the multiline text of a reference's attributes and of attribute definitions in the recolored blocks, as it does for MTEXT and multileader text. --- crates/engine/src/cmd/file/compare/export.rs | 16 +++++-- crates/engine/tests/compare_export.rs | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 9d24c1aef..078006781 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -355,13 +355,21 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep }; for a in attribs.into_iter().flatten() { a.props.color = Some(color); + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } } - // Colors an object carries apart from its own: inline MTEXT colors, a dimension style's - // DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element colors, the style colors of - // a table or a tolerance (through a recolored copy of the style) and inline cell colors. Hatch - // backgrounds, multiline fills and table cell fills are left out. + // Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes + // too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element + // colors, the style colors of a table or a tolerance (through a recolored copy of the style) + // and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out. match &mut e.kind { EntityKind::MText(t) => t.contents = without_color_codes(&t.contents), + EntityKind::AttDef(a) => { + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } + } EntityKind::MLeader(m) => { if let Some(t) = m.text.as_mut() { t.contents = without_color_codes(&t.contents); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index a38d08482..643c8b86b 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -518,6 +518,51 @@ fn colors_carried_apart_from_the_object_color_are_replaced() { assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1)); } +#[test] +fn inline_colors_of_multiline_attributes_are_replaced() { + use cadcraft_engine::doc::Attrib; + let attrib = |contents: &str| -> Attrib { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + serde_json::from_value(json!({ + "tag": "T", + "text": {"insert": at, "height": 1.0, "value": "x"}, + "multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"}, + })) + .unwrap() + }; + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") }; + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone()); + let mut blocks = Vec::new(); + for e in members(&s, r["current"].as_str().unwrap()) { + if let EntityKind::Insert(i) = &e.kind { + assert_eq!(i.attribs.iter().map(multiline).collect::>(), [Some("x".to_string())], "the reference's attribute"); + blocks.push(i.block.clone()); + } + } + assert_eq!(blocks.len(), 1); + let d = s.doc().unwrap(); + let defs: Vec> = d + .block(&blocks[0]) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::AttDef(a) => Some(multiline(a)), + _ => None, + }) + .collect(); + assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block"); +} + #[test] fn edits_after_compare_count_and_objects_keep_their_layers() { let mut s = Session::new(); From 17952af3fb19cf8945f39faa759ef44fa2baddc0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:33:36 +0000 Subject: [PATCH 24/91] Regenerate the parity checklist for the batch --- docs/parity-checklist.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a25961050..9c2186c90 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,19 +1,19 @@ # Parity checklist -> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. +**Menu breadth: 296 / 491 items (60%)** · 441 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | -| Edit | 13 | 11 | 85% | -| View | 75 | 31 | 41% | +| Edit | 13 | 12 | 92% | +| View | 75 | 36 | 48% | | Insert | 11 | 5 | 45% | | Format | 27 | 26 | 96% | -| Tools | 85 | 64 | 75% | +| Tools | 85 | 66 | 78% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -32,7 +32,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - File > Markup Import... - File > Share... - Edit > Undo Group of commands -- Edit > Cut with Base Point - View > Pan > Point - View > Orbit > Constrained Orbit - View > Orbit > Free Orbit @@ -67,14 +66,9 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Render > Light > New Distant Light - View > Render > Light > Light Glyphs - View > Render > Materials Browser -- View > UCS Icon > On - View > UCS Icon > Origin - View > UCS Icon > Properties... -- View > ViewCube > On - View > ViewCube > Settings -- View > Attribute Display > Normal -- View > Attribute Display > On -- View > Attribute Display > Off - View > Lights - View > Enter Full Screen - Insert > PDF Underlay... @@ -85,7 +79,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Insert > Drawing Exchange Binary... - Format > Transparency - Tools > Spelling -- Tools > Inquiry > Volume - Tools > Update Fields - Tools > Edit Xref in Place - Tools > New UCS > World @@ -101,7 +94,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > Customize > Interface (CUI)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... - Tools > Express Tools > Super Hatch > Select Existing Object From 429554b389f74f56854ab9e39b4c5ce9a6860274 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:35:48 +0000 Subject: [PATCH 25/91] COMPAREEXPORT: tests move into the engine's it suite (the hook test joins tests/compare.rs); the copy drops constraint display exceptions for objects it doesn't hold --- crates/engine/src/cmd/file/compare/export.rs | 3 + crates/engine/tests/compare.rs | 73 ++++++++++++++++++- .../engine/tests/{ => it}/compare_export.rs | 52 +------------ crates/engine/tests/it/main.rs | 1 + 4 files changed, 75 insertions(+), 54 deletions(-) rename crates/engine/tests/{ => it}/compare_export.rs (92%) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 9d24c1aef..5041ac6e4 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -187,6 +187,9 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot { d.foreign = Arc::default(); d.groups.clear(); d.constraints.clear(); + // The per-object constraint bar and dimension exceptions name objects the copy doesn't have. + d.parametric.settings.bar_exceptions.clear(); + d.parametric.settings.dim_exceptions.clear(); d.hidden_objects.clear(); d.read_notes.clear(); d.refedit = None; diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index bbaeca3b0..0e4486ccb 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -104,16 +104,40 @@ fn navigates_areas_and_closes() { assert!(s.compare.is_none()); } -#[test] -fn compares_a_file_read_through_the_io_hooks() { - // Test file format: the bytes are "x" — a drawing holding a line from 0,0 to x,0. +/// The file-reading hook both tests here share (it is set once per process). Test file format: +/// "xref" is a drawing with a reference to xref Y at `y.dwg`; a number x is a drawing holding a +/// line from 0,0 to x,0. +fn hooks() { fn read(bytes: &[u8], _name: &str) -> Result { - let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?; let mut d = Drawing::new_imperial(); + if bytes == b"xref" { + let mut y = Block::new("Y"); + y.xref_path = Some("y.dwg".into()); + d.blocks.insert("Y".into(), Arc::new(y)); + let insert = Insert { + block: "Y".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?; + return Ok(d); + } + let x: f64 = String::from_utf8_lossy(bytes).trim().parse().map_err(|_| "bad test file")?; d.add(&Space::Model, Common::default(), line(0.0, 0.0, x, 0.0)).map_err(|e| e.to_string())?; Ok(d) } set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); +} + +#[test] +fn compares_a_file_read_through_the_io_hooks() { + hooks(); let dir = std::env::temp_dir().join(format!("cadcraft-compare-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("other.dxf"); @@ -194,3 +218,44 @@ fn derived_dimension_fields_are_not_differences() { let r = s.execute("compare", &json!({"document": 0})).unwrap(); assert_eq!(counts(&r), (0, 0, 0, 1), "{r}"); } + +/// COMPAREEXPORT (#2049): an xref path relative to the compared file keeps that file's folder. +#[test] +fn relative_xref_paths_of_a_compared_file_take_that_files_folder() { + hooks(); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("other.dxf"); + std::fs::write(&path, "xref").unwrap(); + // Named by its absolute path and by a path relative to the working directory. + let mut names = vec![path.to_string_lossy().to_string()]; + names.extend(relative_to_cwd(&path)); + for name in names { + let mut s = Session::new(); + s.execute("compare", &json!({"path": name})).unwrap(); + // The compared file's xref is only there; the copy keeps the file's folder. + s.execute("compareexport", &json!({})).unwrap(); + let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap(); + let got = std::path::Path::new(&got); + assert!(got.is_absolute(), "{got:?}, compared as {name}"); + assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg"))); + assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}"); + } + std::fs::remove_dir_all(&dir).ok(); +} + +/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when +/// the two share a root. +fn relative_to_cwd(p: &std::path::Path) -> Option { + use std::path::Component; + let cwd = std::env::current_dir().ok()?; + let (a, b): (Vec, Vec) = (cwd.components().collect(), p.components().collect()); + let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + if shared == 0 { + return None; + } + let mut rel = std::path::PathBuf::new(); + a[shared..].iter().for_each(|_| rel.push("..")); + b[shared..].iter().for_each(|c| rel.push(c)); + Some(rel.to_string_lossy().to_string()) +} diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/it/compare_export.rs similarity index 92% rename from crates/engine/tests/compare_export.rs rename to crates/engine/tests/it/compare_export.rs index 8a2b0aa47..c1e3fa56f 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -1,5 +1,6 @@ //! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and -//! the other drawing's differing objects in another, in the comparison colors. +//! the other drawing's differing objects in another, in the comparison colors. A compared file's +//! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`. use std::sync::Arc; @@ -508,55 +509,6 @@ fn relative_xref_paths_become_paths_from_the_source_folder() { assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}"); } -#[test] -fn relative_xref_paths_of_a_compared_file_take_that_files_folder() { - // Test file format: any bytes read as a drawing with a reference to xref Y at `y.dwg`. - fn read(_bytes: &[u8], _name: &str) -> Result { - let mut d = Drawing::new_imperial(); - let mut y = Block::new("Y"); - y.xref_path = Some("y.dwg".into()); - d.blocks.insert("Y".into(), Arc::new(y)); - d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("Y"))).map_err(|e| e.to_string())?; - Ok(d) - } - cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); - let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("other.dxf"); - std::fs::write(&path, "x").unwrap(); - // Named by its absolute path and by a path relative to the working directory. - let mut names = vec![path.to_string_lossy().to_string()]; - names.extend(relative_to_cwd(&path)); - for name in names { - let mut s = Session::new(); - s.execute("compare", &json!({"path": name})).unwrap(); - // The compared file's xref is only there; the copy keeps the file's folder. - s.execute("compareexport", &json!({})).unwrap(); - let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap(); - let got = std::path::Path::new(&got); - assert!(got.is_absolute(), "{got:?}, compared as {name}"); - assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg"))); - assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}"); - } - std::fs::remove_dir_all(&dir).ok(); -} - -/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when -/// the two share a root. -fn relative_to_cwd(p: &std::path::Path) -> Option { - use std::path::Component; - let cwd = std::env::current_dir().ok()?; - let (a, b): (Vec, Vec) = (cwd.components().collect(), p.components().collect()); - let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); - if shared == 0 { - return None; - } - let mut rel = std::path::PathBuf::new(); - a[shared..].iter().for_each(|_| rel.push("..")); - b[shared..].iter().for_each(|c| rel.push(c)); - Some(rel.to_string_lossy().to_string()) -} - #[test] fn says_when_blocks_of_the_other_drawing_were_left_out() { let mut other = Drawing::new_imperial(); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7649e68fc..8babc2c49 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -17,6 +17,7 @@ mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod compare_export; mod constraint_geometry; mod constraint_infer; mod count; From 4dbcbfdaaa070e7f904f6113f2b2e8c52dc9b4c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:37:44 -0700 Subject: [PATCH 26/91] COMPAREEXPORT: dimension block copies stay *D blocks, and image paths count in block reuse A dimension's block took a recolored copy named COMPARE__D1, and the DXF writer redraws a dimension whose block is not a *D block, so saving the snapshot replaced geometry read from a file. The copy now gets a free *D name. A named block both drawings define was reused when its images and data links had the same relative paths, even when they were relative to different folders. These paths are now taken from each drawing's folder before the blocks are compared. --- crates/engine/src/cmd/file/compare.rs | 13 ++- crates/engine/src/cmd/file/compare/export.rs | 18 +++- crates/engine/tests/compare_export.rs | 97 +++++++++++++++++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 58629f025..d4e2878e8 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -140,7 +140,8 @@ struct Signer<'a> { blocks: HashMap, work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay - /// flags and xref file, with a relative xref path taken from the folder given, and the + /// flags and xref file, with relative xref, image and data link paths taken from the folder + /// given, and the /// signatures of a multileader and a dimension cover their blocks'. Each object in a block /// then also counts with its visibility, transparency, thickness and extrusion, and an /// anonymous block's signature leaves out its name. @@ -213,7 +214,15 @@ impl<'a> Signer<'a> { break; } self.work += 1; - self.geometry(&e.kind, depth + 1).hash(&mut h); + match self.frames { + // An image's file and a table's data link, from the folder given. + Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => { + let mut k = e.kind.clone(); + export::rebase(&mut k, folder); + self.geometry(&k, depth + 1).hash(&mut h); + } + _ => self.geometry(&e.kind, depth + 1).hash(&mut h), + } properties(e).hash(&mut h); if self.frames.is_some() { let c = &e.common; diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 078006781..88a1c4350 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -5,8 +5,9 @@ //! only in this drawing or changed in it are drawn in the overlay's green, objects only in the //! other drawing or changed there in its red, and objects in both in gray (color 8). A block //! reference, dimension or multileader refers to a copy of its block whose objects all have that -//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`), and a -//! table or tolerance uses a recolored copy of its style; xrefs keep their own colors. The new +//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`; for a +//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy +//! of its style; xrefs keep their own colors. The new //! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other //! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of //! the other drawing whose name this one uses is copied under a free name. Relative xref, image @@ -130,7 +131,7 @@ pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) { } /// Rebase the file an object refers to: an image's file, a table's data link. -fn rebase(k: &mut EntityKind, folder: Option<&Path>) { +pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) { match k { EntityKind::Image(i) => rebase_path(&mut i.path, folder), EntityKind::Table(t) => { @@ -518,11 +519,18 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, return None; } tinted.copied += b.entities.len(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()); + // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of + // drawing the dimension again. + let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let new = if dim_block { + (1..=d.blocks.len() + 1).map(|i| format!("*D{i}")).find(|n| d.block(n).is_none()).unwrap_or_else(|| format!("*D{}", d.blocks.len() + 2)) + } else { + free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()) + }; tinted.names.insert(key, new.clone()); // Hold the name while the nested copies pick theirs. d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); - let mut copy = Block { name: new.clone(), anonymous: false, entities: EntityStore::new(), ..(*b).clone() }; + let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() }; for e in b.entities.iter() { let h = d.new_handle(); let mut e = Entity { handle: h, ..(**e).clone() }; diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 643c8b86b..c001f6bd7 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -397,9 +397,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); s.execute("compareexport", &json!({})).unwrap(); - let d = s.doc().unwrap(); - assert!(d.block("PART_2").is_none(), "PART draws the same thing in both drawings"); - assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); + assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings"); } #[test] @@ -430,6 +428,99 @@ fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_ assert!(!run(Common::default()), "the same line"); } +#[test] +fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() { + // PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is + // a file in another folder, so its `a.png` is another file. + let part = |first: u64| { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Image(image))); + part + }; + let dir = std::env::temp_dir().join("compareexport-image-folder"); + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string())); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let paths: Vec = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(paths.len(), 1, "the other drawing's PART is copied"); + assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path())); +} + +#[test] +fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // The dimension's block holds a line its parameters would not draw: a block read from a file. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut s = Session::new(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x701), line(123.0))); + s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let block = read + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ends: Vec = read + .block(&block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect(); + assert_eq!(ends, [123.0], "the saved dimension block is the copy's"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 8171c9be176909fe7d2be5b8e97a3d7f8a6f1a06 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:43:34 +0000 Subject: [PATCH 27/91] DIMTFILL and DIMTFILLCLR are written in DIMSTYLE only when set; COMPAREEXPORT redraws dimensions instead of copying their blocks, and gaps.md notes its groups --- crates/engine/src/cmd/file/compare/export.rs | 3 ++ crates/engine/tests/it/compare_export.rs | 7 ++++ crates/io/src/dxf_write.rs | 7 +++- crates/io/tests/it/dimtfill_roundtrip.rs | 37 ++++++++++++++++++-- docs/gaps.md | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 5041ac6e4..0bb063cda 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -371,6 +371,9 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } } EntityKind::Dimension(dm) => { + // Drawn again from its geometry in the object's color; a named copy of its `*D` + // block would stay behind in saved files. + dm.block = None; if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index c1e3fa56f..2d2e13905 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -351,6 +351,13 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( .unwrap_or_default(); assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); assert_eq!(ends(&s, "*D2"), vec![5.0]); + // The colored copies redraw their dimensions instead of keeping colored copies of `*D` blocks. + let d = s.doc().unwrap(); + let dim_copy = |n: &str| { + n.starts_with("COMPARE_") + && n.rsplit('_').next().is_some_and(|t| t.len() > 1 && t.starts_with('D') && t[1..].bytes().all(|c| c.is_ascii_digit())) + }; + assert!(!d.blocks.keys().any(|n| dim_copy(n)), "{:?}", d.blocks.keys().collect::>()); } #[test] diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 2924a0078..06c8b648f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2309,8 +2309,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.dimval(*code, enc, false); continue; } - // Table groups where the file version has them, else xdata. + // Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR + // are written only when set, as other writers do, so a style without a text + // background has a single 70 (its flags). if version >= dxf_ext::newer_since(*code) { + if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) { + continue; + } w.dimval(*code, enc, false); } else if v.get(*field) != defaults.get(*field) { newer.i(1070, i64::from(*code)); diff --git a/crates/io/tests/it/dimtfill_roundtrip.rs b/crates/io/tests/it/dimtfill_roundtrip.rs index 2f574df5a..5e1b4f1bf 100644 --- a/crates/io/tests/it/dimtfill_roundtrip.rs +++ b/crates/io/tests/it/dimtfill_roundtrip.rs @@ -1,6 +1,6 @@ //! DIMTFILL (group 69) and DIMTFILLCLR (group 70) survive DXF: as DIMSTYLE groups from R2007, -//! after the record's own flags (also 70), and in the style's `CADCRAFT` "DIMVARS" xdata in older -//! files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID. +//! after the record's own flags (also 70) and only when set, and in the style's `CADCRAFT` +//! "DIMVARS" xdata in older files. The `*D` block of a dimension with DIMTFILL 2 holds the background as a SOLID. use cadcraft_color::Color; use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, Space}; @@ -54,6 +54,39 @@ fn text_fill_roundtrips_in_the_dimstyle_table() { } } +#[test] +fn unset_text_fill_writes_no_groups() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "Plain".into(), ..DimStyle::default() }); + d.dim_styles.push(DimStyle { name: "Canvas".into(), text_fill: 1, ..DimStyle::default() }); + let text = dxf(&d, Version::R2018); + let groups = |name| -> Vec<(String, String)> { + record(&text, name).chunks(2).filter_map(|p| Some((p.first()?.to_string(), p.get(1)?.to_string()))).collect() + }; + let plain = groups("Plain"); + // The record's flags are its only 70. + assert_eq!(plain.iter().filter(|g| g.0 == "70").count(), 1, "{plain:?}"); + assert!(!plain.iter().any(|g| g.0 == "69"), "{plain:?}"); + let canvas = groups("Canvas"); + assert!(canvas.contains(&("69".into(), "1".into())), "{canvas:?}"); + assert_eq!(canvas.iter().filter(|g| g.0 == "70").count(), 1, "the default DIMTFILLCLR is not written: {canvas:?}"); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.dim_style("Plain").unwrap().text_fill, 0); + assert_eq!(back.dim_style("Canvas").unwrap().text_fill, 1); +} + +#[test] +fn hostile_text_fill_groups_fall_back_to_defaults() { + let text = dxf(&drawing(), Version::R2018); + for (fill, color) in [("99", "3"), ("-5", "3"), ("2", "40000"), ("2", "-7")] { + let edited = text.replacen(" 69\r\n2\r\n 70\r\n3\r\n", &format!(" 69\r\n{fill}\r\n 70\r\n{color}\r\n"), 1); + assert_ne!(edited, text, "groups replaced"); + let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap(); + let st = back.dim_style("Fill").unwrap(); + assert!(st.text_fill <= 2, "{fill}/{color}: {}", st.text_fill); + } +} + #[test] fn record_flags_are_not_read_as_dimtfillclr() { let text = dxf(&drawing(), Version::R2018); diff --git a/docs/gaps.md b/docs/gaps.md index a08972e55..5f8e2833e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -43,7 +43,7 @@ do them first. | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | -| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | +| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | From 53049d82e0fe0dd5de1a782d13a53a24fea95bcc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:58:15 +0000 Subject: [PATCH 28/91] COMPAREEXPORT: keep #2049's *D copies of dimension blocks --- crates/engine/src/cmd/file/compare/export.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 7aab32b0d..4e6d28401 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -380,9 +380,6 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } } EntityKind::Dimension(dm) => { - // Drawn again from its geometry in the object's color; a named copy of its `*D` - // block would stay behind in saved files. - dm.block = None; if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); From 642c3a59a33ea549413ecb560a09e73987fbed1f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:04:14 +0000 Subject: [PATCH 29/91] COMPAREEXPORT: free block and style names come from a next-index counter per name base instead of a scan from 1 --- crates/engine/src/cmd/file/compare/export.rs | 55 ++++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 4e6d28401..5988c51d9 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -327,6 +327,34 @@ struct Tinted { uncolored: bool, /// Objects copied into block definitions so far, counted against [`MAX_COPIED`]. copied: usize, + next: NextFree, + /// The drawing's block names in upper case, filled on the first block copy. Only + /// [`tinted_block`] adds blocks after that, and it adds their names here too. + blocks: Option>, +} + +/// The next index to try for each name base. Names are only added while a snapshot is built, +/// so an index found taken stays taken, and each search starts where the last one for the same +/// base stopped: a name costs O(1) amortised instead of a scan from 1. +#[derive(Default)] +struct NextFree(HashMap<(&'static str, String), usize>); + +impl NextFree { + /// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices + /// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are, + /// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name. + fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String { + let key = (kind, base.to_ascii_uppercase()); + let start = self.0.get(&key).copied().unwrap_or(1); + let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start)); + self.0.insert(key, i.saturating_add(1)); + name(i) + } +} + +/// `base` for 1, `base_2`, `base_3`… after it. +fn suffixed(base: &str, i: usize) -> String { + if i == 1 { base.to_string() } else { format!("{base}_{i}") } } /// The word a comparison color adds to the names of its recolored copies. @@ -342,10 +370,7 @@ fn tag(color: Color) -> &'static str { /// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are. fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String { - (1..=n + 1) - .map(|i| if i == 1 { base.to_string() } else { format!("{base}_{i}") }) - .find(|c| !taken(c)) - .unwrap_or_else(|| format!("{base}_{}", n + 2)) + (1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2)) } /// Give an object its comparison color: its own color, its attributes' colors, and, through @@ -429,7 +454,8 @@ fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tint return Some(n.clone()); } let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.dim_styles.len(), |n| { + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find("dimension style", &base, d.dim_styles.len(), |i| suffixed(&base, i), |n| { d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) }); d.dim_styles.push(DimStyle { @@ -451,7 +477,8 @@ fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Ti return Some(n.clone()); } let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.table_styles.len(), |n| { + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find("table style", &base, d.table_styles.len(), |i| suffixed(&base, i), |n| { d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) }); let names = @@ -525,11 +552,16 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of // drawing the dimension again. let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let Tinted { next, blocks, .. } = tinted; + let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect()); + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); let new = if dim_block { - (1..=d.blocks.len() + 1).map(|i| format!("*D{i}")).find(|n| d.block(n).is_none()).unwrap_or_else(|| format!("*D{}", d.blocks.len() + 2)) + next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken) } else { - free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()) + let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')); + next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken) }; + taken.insert(new.to_ascii_uppercase()); tinted.names.insert(key, new.clone()); // Hold the name while the nested copies pick theirs. d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); @@ -605,18 +637,19 @@ fn copy_blocks<'a>( // Names `d` does not use stay; the others get free names, avoiding the kept ones. let mut renames = Renames::new(); let mut taken: HashSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + let mut next = NextFree::default(); for b in needed.iter().filter(|b| d.block(&b.name).is_none()) { taken.insert(b.name.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); } for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); let free = if b.anonymous { let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); - (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) + next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken) } else { - Some(free_name(&b.name, taken.len(), |n| taken.contains(&n.to_ascii_uppercase()))) + next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken) }; - let Some(free) = free else { continue }; taken.insert(free.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), free); } From c42763ba0f50c552bb1e31cab8d4ce0a47ca480b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:12:57 +0000 Subject: [PATCH 30/91] CUTCLIP and CUTBASE leave the clipboard alone when every object is on a locked layer, and say how many were locked --- crates/engine/src/cmd/edit.rs | 44 +++++++++++++++++++++++++------- crates/engine/tests/clipboard.rs | 31 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6cb4412d..dab5d4eb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -463,29 +463,53 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { Ok(json!({ "copied": n })) } -/// Copy the objects that are not on locked layers to the clipboard and erase them. -fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { - let hs: Vec = hs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); +/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how +/// many were cut and the message to show: how many were on a locked layer, and that nothing was +/// cut when that is all of them (the clipboard then keeps what it held). +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result<(usize, Option)> { + let mut seen = std::collections::HashSet::new(); + let live: Vec = { + let d = s.doc()?; + hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect() + }; + let (hs, locked): (Vec, Vec) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h)); + let note = match locked.len() { + 0 => None, + 1 => Some("1 object was on a locked layer.".to_string()), + n => Some(format!("{n} objects were on a locked layer.")), + }; + if hs.is_empty() { + let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut.")); + return Ok((0, Some(msg))); + } let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); - Ok(n) + Ok((n, note)) +} + +fn cut_result(n: usize, msg: Option) -> Value { + let mut r = json!({ "cut": n }); + if let Some(m) = msg { + r["message"] = json!(m); + } + r } fn run_cut(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, None)?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, None)?; + Ok(cut_result(n, msg)) } fn run_cutbase(s: &mut Session, p: &Value) -> Result { let base = point_req("cutbase", p, "base")?; let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, Some(base))?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, Some(base))?; + Ok(cut_result(n, msg)) } /// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its @@ -680,7 +704,9 @@ struct CopyBaseM { impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { if self.cut { - cut_to_clip(s, hs, self.base)?; + if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? { + s.echo(m); + } } else { copy_to_clip(s, hs, self.base)?; } diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index c410db91c..650d63092 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -182,7 +182,7 @@ fn cutbase_cuts_with_the_given_base_point() { s.active = a; let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); // The wall is on a locked layer, so only the door is cut. - assert_eq!(r, json!({ "cut": 1 })); + assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." })); assert_eq!(s.clipboard.len(), 1); assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); let left = model(&s); @@ -201,6 +201,35 @@ fn cutbase_cuts_with_the_given_base_point() { assert_eq!(s.doc().unwrap().model.len(), 2); } +#[test] +fn cutting_only_locked_objects_keeps_the_clipboard() { + let mut s = Session::empty(); + let mut da = drawing_a(); + for l in &mut da.layers { + l.locked = true; + } + s.open_drawing(da, "A", None); + let hs = handles(&s); + s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap(); + let held = s.clipboard.clone(); + let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap(); + assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." })); + assert_eq!(model(&s).len(), 2, "nothing was erased"); + assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held"); + assert_eq!(s.clipboard[0].handle, held[0].handle); + + // CUTBASE at the command line says so too. + s.start("cutbase").unwrap(); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + let wall = s.doc().unwrap().model.iter().next().unwrap().handle; + s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 2); +} + #[test] fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { let mut s = Session::empty(); From b90d68ec343c8f351852aa95d2aebfd3965e01be Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:17:52 +0000 Subject: [PATCH 31/91] BLOCKREPLACE: a dynamic block or a replacement that references the block is refused when its name is given, and the prompt asks again --- crates/engine/src/cmd/blocks/blockreplace.rs | 32 +++++++++++++----- crates/engine/tests/it/blockreplace.rs | 35 +++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs index c20012784..03bac655d 100644 --- a/crates/engine/src/cmd/blocks/blockreplace.rs +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -27,6 +27,20 @@ fn named_block(d: &Drawing, name: &str) -> Option { d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone()) } +/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their +/// property values in data that names the block. +fn not_replaceable(d: &Drawing, old: &str) -> Option { + cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced.")) +} + +/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new` +/// reference itself. +fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option { + let blk = d.block(new)?; + references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), old) + .then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it.")) +} + /// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s /// definition when nothing references it afterwards. Returns (references replaced, purged). fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> { @@ -46,15 +60,10 @@ fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, if old.eq_ignore_ascii_case(&new) { return Err(bad(cmd, "the replacement block must be a different block")); } - // A dynamic block's references keep their property values in data that names the block. - if cadcraft_doc::dynblock::dynamic_block(d, &old).is_some() { - return Err(bad(cmd, format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))); + if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) { + return Err(bad(cmd, why)); } let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?; - // References of `old` inside `new` would make `new` reference itself. - if references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), &old) { - return Err(bad(cmd, format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))); - } let defs = attdefs(&blk); let scale = d.current_anno_scale(); let d = s.doc_mut()?; @@ -132,17 +141,24 @@ struct BlockreplaceM { } impl BlockreplaceM { - /// Take `name` as the answer to the current name prompt. + /// Take `name` as the answer to the current name prompt. A block that cannot take part is + /// refused here, so the prompt asks again. fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> { let d = s.doc()?; let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?; if stage == Stage::Old { + if let Some(why) = not_replaceable(d, &name) { + return Err(EngineError::Other(why)); + } self.old = name; self.stage = Stage::New; } else { if name.eq_ignore_ascii_case(&self.old) { return Err(EngineError::Other("The replacement block must be a different block.".into())); } + if let Some(why) = cannot_replace_with(d, &self.old, &name) { + return Err(EngineError::Other(why)); + } self.new = name; self.stage = Stage::Purge; } diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index eefb23f1f..2cd18c81c 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -2,7 +2,8 @@ //! other blocks, becomes a reference of another at the same insertion point, scale and rotation. use cadcraft_engine::doc::geom::Vec3; -use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; use cadcraft_engine::{Input, Session}; use serde_json::{Value, json}; @@ -184,3 +185,35 @@ fn blockreplace_no_keeps_the_replaced_definition() { assert!(d.block("A").is_some()); assert_eq!(refs(d.model.iter()), ["N", "B"]); } + +#[test] +fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + // N holds a reference of A: refused at once, and the prompt asks for another name. + s.cmdline("N").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A?"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + + // A dynamic block is refused as the block to replace. + let mut d = s.doc().unwrap().clone(); + let mut f = Foreign::default(); + f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); + let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); + f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]) }); + f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); + d.foreign = std::sync::Arc::new(f); + s.open_drawing(d, "dynamic", None); + let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string(); + assert!(e.contains("dynamic block"), "{e}"); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); +} From 8296bbc6428ff2a542ebc2dbc2d0ade8265707ca Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:22:57 +0000 Subject: [PATCH 32/91] THICKNESS: a typed value out of range is reported as such and the prompt asks again --- crates/engine/src/cmd/props.rs | 24 +++++++++++++++++++----- crates/engine/src/tests.rs | 6 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 75dc0b046..c89204c22 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -539,10 +539,17 @@ impl Interactive for LtscaleM { } } +/// The largest THICKNESS, either way. +const MAX_THICKNESS: f64 = 1e12; + +fn thickness_in_range(t: f64) -> bool { + t.is_finite() && t.abs() <= MAX_THICKNESS +} + fn run_thickness(s: &mut Session, p: &Value) -> Result { let t = f64_req("thickness", p, "thickness")?; - if !t.is_finite() || t.abs() > 1e12 { - return Err(bad("thickness", "thickness is out of range")); + if !thickness_in_range(t) { + return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)")); } s.doc_mut()?.header.set_f64("THICKNESS", t); Ok(json!({ "thickness": t })) @@ -565,9 +572,16 @@ impl Interactive for ThicknessM { Input::Text(t) => t, _ => return Ok(Step::Continue), }; - match super::machines::number(&text).map(|t| run_thickness(s, &json!({ "thickness": t }))) { - Some(Ok(_)) => Ok(Step::Done), - _ => { + match super::machines::number(&text) { + Some(t) if thickness_in_range(t) => { + run_thickness(s, &json!({ "thickness": t }))?; + Ok(Step::Done) + } + Some(_) => { + s.echo("Requires a value between -1e12 and 1e12."); + Ok(Step::Continue) + } + None => { s.echo("Requires a number."); Ok(Step::Continue) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 731256107..8c590dd97 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1317,6 +1317,12 @@ fn format_thickness_asks_for_the_thickness() { // After text that is not a number, the prompt asks again. s.cmdline("thick").unwrap(); assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a number.")); + // A value out of range says so, and the prompt asks again. + s.cmdline("2e12").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12.")); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0); // A negative thickness is accepted. s.cmdline("-1.5").unwrap(); assert!(s.current_prompt().is_none()); From 23d44cd9c88fc82fc54ba1a5e4ad0193b63fa5f1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:26:14 +0000 Subject: [PATCH 33/91] MEASUREGEOM Volume: the height can be given by two points, and Enter at the height prompt says a height is required --- crates/engine/src/cmd/inquiry.rs | 43 ++++++++++++++++------ crates/engine/tests/it/inquiry_keywords.rs | 8 ++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 26f074a96..9962c3fd6 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -933,6 +933,8 @@ pub(super) struct AreaM { volume: bool, /// Areas measured in volume mode that still wait for their height, oldest first. pending: Vec, + /// The first point of a height given by two points. + height_from: Option, } impl AreaM { @@ -960,6 +962,19 @@ impl AreaM { AreaMode::Subtract => self.total - v, } } + /// Measure the oldest pending area's volume with height `h`. + fn height(&mut self, s: &mut Session, h: f64) -> Step { + match self.pending.first().map(|a| a * h.abs()) { + Some(v) if v.is_finite() && self.total_with(v).is_finite() => { + self.pending.remove(0); + self.volume_measured(s, v) + } + _ => { + s.echo("The volume is too large."); + Step::Continue + } + } + } /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { s.echo(volume_text(s, v)); @@ -1003,7 +1018,10 @@ impl Interactive for AreaM { } fn prompt(&self, _s: &Session) -> Prompt { if !self.pending.is_empty() { - return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER); + return match self.height_from { + Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p), + None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER), + }; } if self.object { return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); @@ -1023,17 +1041,20 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - if let Input::Text(t) = i { - match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => match self.pending.first().map(|a| a * h.abs()) { - Some(v) if v.is_finite() && self.total_with(v).is_finite() => { - self.pending.remove(0); - return Ok(self.volume_measured(s, v)); - } - _ => s.echo("The volume is too large."), - }, - None => s.echo("Requires a distance."), + // A height is typed, or given by two points (their distance). + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return Ok(self.height(s, p.dist(from))); } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return Ok(self.height(s, h)), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + _ => {} } return Ok(Step::Continue); } diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 1a46bc5c9..5a2c4cb6e 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -229,6 +229,14 @@ fn measuregeom_volume_multiplies_an_area_by_a_height() { assert!(has(&log, "Requires a distance."), "{log:?}"); assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + // Enter is not a height; two points give the height as their distance. + let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]); + assert!(has(&log, "Requires a height."), "{log:?}"); + assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text()); + s.cmdline("7,9").unwrap(); + assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // The JSON forms, by points and by handle. let mut s = Session::new(); let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap(); From 3d7e298b7de55b8a1199765d1221e1e854732f06 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:26:38 +0000 Subject: [PATCH 34/91] gaps.md: F7 notes the DIMTFILL 1 and true-color DIMTFILLCLR limits of written files --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 5f8e2833e..a4a12cff8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (DIMTFILL and DIMTFILLCLR: F7 narrowed; COMPAREEXPORT exports a comparison to a new drawing: F22 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -29,7 +29,7 @@ do them first. | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | -| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .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 | From b34477d580eded0218d4abf4b3aab69a6a90014a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:41:25 +0000 Subject: [PATCH 35/91] cargo fmt --- crates/engine/src/cmd/file/compare/export.rs | 20 ++++++++++++++------ crates/engine/tests/it/blockreplace.rs | 8 ++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 5988c51d9..db41abfba 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -455,9 +455,13 @@ fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tint } let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); let base = format!("COMPARE_{}_{}", tag(color), st.name); - let new = tinted.next.find("dimension style", &base, d.dim_styles.len(), |i| suffixed(&base, i), |n| { - d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let new = tinted.next.find( + "dimension style", + &base, + d.dim_styles.len(), + |i| suffixed(&base, i), + |n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); d.dim_styles.push(DimStyle { name: new.clone(), dim_line_color: Color::ByBlock, @@ -478,9 +482,13 @@ fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Ti } let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); let base = format!("COMPARE_{}_{}", tag(color), st.name); - let new = tinted.next.find("table style", &base, d.table_styles.len(), |i| suffixed(&base, i), |n| { - d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let new = tinted.next.find( + "table style", + &base, + d.table_styles.len(), + |i| suffixed(&base, i), + |n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); let names = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone())); let mut cell_styles: Vec = Vec::new(); diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index 2cd18c81c..86b8c42e3 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -1,8 +1,8 @@ //! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and //! other blocks, becomes a reference of another at the same insertion point, scale and rotation. -use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; use cadcraft_engine::{Input, Session}; use serde_json::{Value, json}; @@ -204,7 +204,11 @@ fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { let mut f = Foreign::default(); f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); - f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]) }); + f.objects.push(ForeignObject { + handle: Handle(0xF00), + kind: "DICTIONARY".into(), + tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]), + }); f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); d.foreign = std::sync::Arc::new(f); s.open_drawing(d, "dynamic", None); From c6c3aefe3f864c8420652b2a5eaa068e6cfc1bcc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:06:46 +0000 Subject: [PATCH 36/91] COMPAREEXPORT: a dimension whose block can't be copied is drawn again in the comparison color; Volume's second point says it needs a point; comments say only dimensions leave anonymous block names out --- crates/engine/src/cmd/file/compare.rs | 6 +- crates/engine/src/cmd/file/compare/export.rs | 6 ++ crates/engine/src/cmd/inquiry.rs | 1 + crates/engine/tests/it/compare_export.rs | 85 ++++++++++++++++++-- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index d4e2878e8..3b6837342 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -144,7 +144,8 @@ struct Signer<'a> { /// given, and the /// signatures of a multileader and a dimension cover their blocks'. Each object in a block /// then also counts with its visibility, transparency, thickness and extrusion, and an - /// anonymous block's signature leaves out its name. + /// anonymous block's signature leaves out its name (a dimension also leaves its block's name + /// out of its own geometry; an INSERT or MLEADER keeps it). frames: Option>, } @@ -195,7 +196,8 @@ impl<'a> Signer<'a> { let mut h = std::collections::hash_map::DefaultHasher::new(); let block = self.d.block(name); // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the - // same block can have another generated name in another drawing. + // same block can have another generated name in another drawing. Only a dimension's + // geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references. if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { key.hash(&mut h); } diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index db41abfba..f3801049d 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -439,6 +439,12 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } _ => {} } + if let EntityKind::Dimension(dm) = &mut e.kind { + // A dimension whose block can't be copied (past the limits) is drawn again from its + // geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors. + dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth)); + return; + } each_ref(&mut e.kind, |n| { if let Some(copy) = tinted_block(d, n, color, tinted, depth) { *n = copy; diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 9962c3fd6..49e0424c2 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -1054,6 +1054,7 @@ impl Interactive for AreaM { }, (Input::Enter, None) => s.echo("Requires a height."), (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), _ => {} } return Ok(Step::Continue); diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 36afcdec6..79d28737e 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -351,13 +351,28 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( .unwrap_or_default(); assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); assert_eq!(ends(&s, "*D2"), vec![5.0]); - // The colored copies redraw their dimensions instead of keeping colored copies of `*D` blocks. + // The colored copies of PART refer to colored copies of the dimension block, which are + // anonymous `*D` blocks of their own, not the originals. let d = s.doc().unwrap(); - let dim_copy = |n: &str| { - n.starts_with("COMPARE_") - && n.rsplit('_').next().is_some_and(|t| t.len() > 1 && t.starts_with('D') && t[1..].bytes().all(|c| c.is_ascii_digit())) - }; - assert!(!d.blocks.keys().any(|n| dim_copy(n)), "{:?}", d.blocks.keys().collect::>()); + let copies: Vec = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()), + _ => None, + }) + .collect::>() + }) + .collect(); + assert!(!copies.is_empty(), "PART has colored copies"); + for n in &copies { + assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}"); + assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block"); + } } #[test] @@ -789,6 +804,64 @@ fn says_when_blocks_both_drawings_define_differently_were_left_out() { assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); } +#[test] +fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { + use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH}; + // B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past + // the nesting limit. + let mut s = Session::new(); + let d = s.doc_mut().unwrap(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x600), line(30.0))); + d.blocks.insert("*D1".into(), Arc::new(drawn)); + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + for k in 1..=MAX_BLOCK_DEPTH { + let mut b = Block::new(&format!("B{k}")); + let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) }; + b.entities.push(Entity::new(Handle(0x700 + k as u64), kind)); + d.blocks.insert(b.name.clone(), Arc::new(b)); + } + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let dims: Vec> = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect::>() + }) + .collect(); + // The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s. + assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::>()); +} + #[test] fn needs_a_comparison_of_the_current_drawing() { let mut s = Session::new(); From 1fcf654ea8414ea2ef052ca2b3b77411f2026f78 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:26:26 -0400 Subject: [PATCH 37/91] Release 0.13.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9dc213ab4..19fb936c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,7 +553,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -577,7 +577,7 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -588,14 +588,14 @@ dependencies = [ [[package]] name = "cadcraft-color" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -616,14 +616,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.12.0" +version = "0.13.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "encoding_rs", "log", @@ -632,7 +632,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -653,7 +653,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -663,14 +663,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.12.0" +version = "0.13.0" dependencies = [ "acadrust", "cadcraft-color", @@ -691,7 +691,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -703,7 +703,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -717,7 +717,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -4985,7 +4985,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 54b1bf711..33b31978b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From f2b35e957e208cac1fe72182db214786ad6ffa8b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:48:48 -0600 Subject: [PATCH 38/91] Migrate CADCraft to egui 0.37 (#2119) * egui037: migrate CADCraft UI and preserve font notices * M0: clarify screenshot cancellation after issue * Document migration UI contracts and regression assertions --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 5 + Cargo.lock | 298 ++-- Cargo.toml | 49 +- NOTICE | 34 +- README.md | 3 +- apps/cadcraft-web/Cargo.toml | 3 +- apps/cadcraft-web/src/host.rs | 3 +- apps/cadcraft-web/src/main.rs | 18 +- apps/cadcraft-web/src/upload.rs | 3 +- apps/cadcraft/Cargo.toml | 3 +- apps/cadcraft/src/main.rs | 40 +- apps/cadcraft/src/native_menu.rs | 30 +- crates/engine/src/cmd/file.rs | 10 + crates/engine/src/lib.rs | 14 + crates/engine/tests/it/compare_export.rs | 2 + crates/fonts/src/complex.rs | 10 +- crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/aliasdlg.rs | 15 +- crates/ui-egui/src/animation.rs | 12 +- crates/ui-egui/src/autosave.rs | 4 +- crates/ui-egui/src/bedit.rs | 8 +- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/blockedit.rs | 2 +- crates/ui-egui/src/canvas/compare.rs | 2 +- crates/ui-egui/src/capture.rs | 1046 ++++++++++++ crates/ui-egui/src/chrome.rs | 31 +- crates/ui-egui/src/chrome/annoscale.rs | 2 +- crates/ui-egui/src/clipboard.rs | 4 +- crates/ui-egui/src/closing.rs | 2 +- crates/ui-egui/src/cmdline.rs | 7 +- crates/ui-egui/src/context_menu.rs | 4 +- crates/ui-egui/src/control.rs | 191 ++- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 57 +- crates/ui-egui/src/docking.rs | 5 +- crates/ui-egui/src/dyngrips.rs | 2 +- crates/ui-egui/src/exchange.rs | 10 +- crates/ui-egui/src/hatchdlg.rs | 9 +- crates/ui-egui/src/i18n/tests.rs | 38 +- crates/ui-egui/src/icons.rs | 8 +- crates/ui-egui/src/layers.rs | 32 +- crates/ui-egui/src/lib.rs | 435 +++-- crates/ui-egui/src/managers.rs | 56 +- crates/ui-egui/src/managers/battman.rs | 26 +- crates/ui-egui/src/mtext_editor.rs | 279 +++- crates/ui-egui/src/opening.rs | 2 +- crates/ui-egui/src/options/dialog.rs | 163 +- crates/ui-egui/src/palettes.rs | 53 +- crates/ui-egui/src/palettes/patterns.rs | 7 +- crates/ui-egui/src/palettes/quickcalc.rs | 38 +- crates/ui-egui/src/panel_state.rs | 76 + crates/ui-egui/src/parametric.rs | 53 +- crates/ui-egui/src/plotstyles.rs | 11 +- crates/ui-egui/src/quick.rs | 35 +- crates/ui-egui/src/spacemouse.rs | 3 +- crates/ui-egui/src/theme.rs | 22 +- crates/ui-egui/src/viewcube.rs | 8 +- crates/ui-egui/src/vpscale.rs | 4 +- crates/ui-egui/src/xrefs.rs | 10 +- crates/ui-egui/tests/accessibility_names.rs | 100 ++ crates/ui-egui/tests/it/animation_effects.rs | 11 +- crates/ui-egui/tests/it/docking_workflows.rs | 4 +- .../ui-egui/tests/it/hidden_command_line.rs | 2 +- crates/ui-egui/tests/it/lisp_files.rs | 5 +- crates/ui-egui/tests/it/menubar_hover.rs | 3 +- crates/ui-egui/tests/it/options_dialog.rs | 12 +- crates/ui-egui/tests/it/parameters_rename.rs | 10 +- crates/ui-egui/tests/it/properties_units.rs | 6 +- crates/ui-egui/tests/it/shared_controls.rs | 4 +- crates/ui-egui/tests/options_followups.rs | 9 +- docs/control-protocol.md | 4 +- docs/egui-037-migration-plan.md | 1475 +++++++++++++++++ packaging/env.sh | 2 +- .../linux/flatpak/ai.storyteller.cadcraft.yml | 2 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/cadcraft.wxs | 11 + packaging/windows/package.ps1 | 18 +- 81 files changed, 4364 insertions(+), 629 deletions(-) create mode 100644 crates/ui-egui/src/capture.rs create mode 100644 crates/ui-egui/src/panel_state.rs create mode 100644 crates/ui-egui/tests/accessibility_names.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c13bb23..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 11d44fca4..93be914f2 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -29,4 +29,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cc35e572..368d4f322 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,7 @@ jobs: with: app-name: cadcraft app-title: CADCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 264c7430f..99990cbab 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 19fb936c9..6ab372714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,11 +13,11 @@ dependencies = [ "byteorder", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.2.0", "indexmap", "itoa", "memmap2", - "nom", + "nom 7.1.3", "oem_cp", "once_cell", "rayon", @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -39,57 +39,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -97,9 +91,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -115,13 +109,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -129,11 +123,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -224,6 +219,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -736,6 +732,7 @@ dependencies = [ "serde", "serde_json", "toml", + "web-time", ] [[package]] @@ -959,12 +956,12 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-syntax", "krilla", @@ -974,7 +971,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b" dependencies = [ "egui", "log", @@ -1113,9 +1110,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1124,9 +1120,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1149,6 +1144,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1161,9 +1157,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1183,9 +1178,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1203,32 +1197,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1240,9 +1237,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1267,9 +1263,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1329,9 +1324,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1348,16 +1342,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1443,6 +1438,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1469,6 +1470,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1650,12 +1657,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1810,6 +1817,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1818,7 +1834,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1827,7 +1843,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1839,7 +1855,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-ccitt", ] @@ -1853,7 +1869,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2188,18 +2204,18 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "base64", "bumpalo", @@ -2531,6 +2547,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2928,6 +2953,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2988,6 +3023,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -2999,33 +3045,33 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3042,9 +3088,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3323,7 +3369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types 0.12.7", "once_cell", "yoke", @@ -3506,6 +3552,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4041,6 +4093,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4143,9 +4206,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4158,9 +4221,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -4822,8 +4885,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -4895,6 +4957,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -5261,3 +5341,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 33b31978b..293a1b367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"] version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/cadcraft" [workspace.dependencies] @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } # foundation thiserror = "2" @@ -53,19 +53,18 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } -egui = "0.36" -egui_kittest = "0.36" -eframe = { version = "0.36", default-features = false } -egui-wgpu = "0.36" +# 650b1c0 is the reviewed craft-libs integration: 25da's egui-0.37 port plus 6e7f's +# shared docking `Workspace`/`DockCommand`. Raw 6e7f is egui 0.36 and is not consumable here. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] } +egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_kittest = "0.37" +eframe = { version = "0.37", default-features = false } +egui-wgpu = "0.37" +# Monotonic time on native and web (performance.now on wasm). +web-time = "1.1" rfd = "0.17" arboard = { version = "3.6", default-features = false } -[patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } - [workspace.lints.rust] unsafe_code = "forbid" @@ -88,3 +87,27 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself +# (Cargo ignores dependency-local patches). +[patch.crates-io] +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } + +# Keep the complete egui family and native event bridge on reviewed fork commits. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 22163da72..811da09b1 100644 --- a/NOTICE +++ b/NOTICE @@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/ is available from crates.io and the repository above. -None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and -linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with -the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md, -each under the SIL Open Font License 1.1. +CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code. +The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md. +Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its +ATTRIBUTION.md, each under the SIL Open Font License 1.1. CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc. + + +The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC +Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file +is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. +Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc +(pinned through https://github.com/storytold/craft-libs/tree/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc). +CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ +Pinned licence text: https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/LICENSE + +The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts +and registered through egui's default font definitions. They derive from emoji-icon-font by +John Slegers, under the MIT License. The subset and full face share the following upstream notice. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 72fcdc7f8..dd0abd25d 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,7 @@ numbers): ## Quick start -Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum -remains Rust 1.90. +CADCraft requires Rust 1.95 or later. ```sh git clone https://github.com/storytold/cadcraft diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index 4fb706d77..e5e86a0e3 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa version.workspace = true edition.workspace = true license.workspace = true +rust-version = "1.95" publish = false [lints] @@ -26,7 +27,7 @@ serde_json = { workspace = true } web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] } diff --git a/apps/cadcraft-web/src/host.rs b/apps/cadcraft-web/src/host.rs index 03fe15b22..c8d6380c6 100644 --- a/apps/cadcraft-web/src/host.rs +++ b/apps/cadcraft-web/src/host.rs @@ -95,7 +95,8 @@ impl Bridge { } } - /// After each frame: tell the parent when unsaved changes appear or go away. + /// After each logic callback (including a hidden tab's): tell the parent when unsaved changes + /// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent. pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) { let dirty = app.session.docs.iter().any(|d| d.is_dirty()); if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) { diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 035e1d6cc..f69e02d8a 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -41,18 +41,22 @@ mod web { /// document title when it changed (so the tab and an embedding page name the open /// drawing), and reports unsaved changes to that page. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.1.take_dropped(ctx); + // Background reads are consume-based and run on hidden ticks too. Dropped files are + // input and are read from `ui` only. self.1.drain(&mut self.0); self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches()); self.0.logic(ctx); let drawing = self.0.session.state().ok(); crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title); + // `DirtyReport` suppresses duplicates, so reporting after every logic callback + // (including a hidden tab's) is idempotent and keeps the parent's guard current. if let Some((bridge, reported)) = &self.2 { bridge.report_dirty(&self.0, reported); } } + /// Forward raw input to the web `CadApp` and capture Tab at the command line. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } /// Preferences (interface theme and language) survive reloads; egui state is not kept. @@ -63,11 +67,15 @@ mod web { fn persist_egui_memory(&self) -> bool { false } + /// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); - if let Some((bridge, reported)) = &self.2 { - bridge.report_dirty(&self.0, reported); + let first_pass = ui.ctx().current_pass_index() == 0; + if first_pass { + // `take_dropped` consumes the raw list; a second pass or hidden tick cannot + // re-read the same file. + self.1.take_dropped(ui.ctx()); } + self.0.ui(ui); } } diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs index 1e5b32706..0b567f8b7 100644 --- a/apps/cadcraft-web/src/upload.rs +++ b/apps/cadcraft-web/src/upload.rs @@ -68,8 +68,9 @@ impl Inbox { } /// Read the files dropped on the page this frame (eframe hands them over unread on the web). + /// The list is taken, not cloned: a later pass or hidden tick must not read the same drop. pub fn take_dropped(&self, ctx: &egui::Context) { - for f in ctx.input(|i| i.raw.dropped_files.clone()) { + for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) { if let Some(file) = f.web_file() { self.read(file.clone()); } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 7a46fa9bc..8d9363a67 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -4,6 +4,7 @@ description = "CADCraft desktop app" version.workspace = true edition.workspace = true license.workspace = true +rust-version = "1.95" publish = false default-run = "cadcraft" @@ -16,7 +17,7 @@ cadcraft-ui-egui = { workspace = true } cadcraft-io = { workspace = true } # For the shared per-session control-token helper (control_server.rs authentication). cadcraft-mcp = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } +eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index e80b838b1..6c9af80a8 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -65,37 +65,47 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option) { } impl eframe::App for App { + /// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - #[cfg(target_os = "macos")] - { - if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { - self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); - } - if let Some(m) = &mut self.native_menu { - m.poll(&mut self.cad, ctx); - } - } self.cad.system_animations = self.system_animations.current(); + // Consume control/open/screenshot queues and time-based autosave, including hidden ticks. self.cad.logic(ctx); - self.spacemouse.poll(&mut self.cad, ctx); - self.persist_workspace(false); + // Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which + // never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a + // workspace changed while hidden is persisted. `current_pass_index()` is zero on the first + // pass and on every hidden tick, and non-zero on later passes of a multi-pass frame. + if ctx.current_pass_index() == 0 { + #[cfg(target_os = "macos")] + { + if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { + self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); + } + if let Some(m) = &mut self.native_menu { + m.poll(&mut self.cad, ctx); + } + } + self.spacemouse.poll(&mut self.cad, ctx); + // After `cad.logic`, so a control command's workspace change is saved in the same frame. + self.persist_workspace(false); + } if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } + /// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { follow_desktop(raw, self.desktop_theme.current()); - self.cad.raw_input_hook(raw); + self.cad.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.cad.ui(ui); - self.persist_workspace(false); } /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a /// crash leaves them for recovery). fn on_exit(&mut self) { self.persist_workspace(true); + self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. @@ -232,6 +242,7 @@ fn control_flag(current: Option, value: Option) -> Option { current } +/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe. fn main() -> eframe::Result { // First, so every start-up record is captured; see `logging`. let logger = logging::install(); @@ -280,6 +291,9 @@ fn main() -> eframe::Result { .with_title_shown(false) .with_app_id(APP_ID), persist_window: false, + // CADCraft installs its own capped font chain (`theme::install_fonts`); never run + // system-font discovery as a hidden fallback. + system_font_fallback: false, // A stencil buffer clips the contents of clipped layout viewports on the GPU. stencil_buffer: STENCIL_BITS, ..Default::default() diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 727043558..523d38a52 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -15,6 +15,9 @@ pub struct NativeMenu { submenus: Vec<(String, Submenu)>, predefined: Vec<(&'static str, PredefinedMenuItem)>, language: &'static str, + /// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh + /// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first + /// ~300 ms of menu enablement. last_refresh: f64, /// The recent files File ▸ Open Recent lists now (it is rebuilt when they change). recent: Vec, @@ -30,6 +33,7 @@ fn accel(sc: &str) -> Option { } impl NativeMenu { + /// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus. pub fn install(app: &mut CadApp) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); @@ -66,7 +70,7 @@ impl NativeMenu { submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter } + Self { _menu: menu, items, submenus, predefined, language, last_refresh: f64::NEG_INFINITY, recent: app.ui.recent_files.clone(), counter } } /// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native @@ -87,6 +91,7 @@ impl NativeMenu { build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language); } + /// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed. pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { while let Ok(ev) = MenuEvent::receiver().try_recv() { if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { @@ -113,7 +118,7 @@ impl NativeMenu { } } let now = cadcraft_ui_egui::now_ms(); - if now - self.last_refresh < 300.0 { + if !refresh_due(now, self.last_refresh) { return; } self.last_refresh = now; @@ -124,6 +129,13 @@ impl NativeMenu { } } +/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is +/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would +/// suppress the first refresh for 300 ms. +fn refresh_due(now: f64, last_refresh: f64) -> bool { + now - last_refresh >= 300.0 +} + fn build( parent: &Submenu, entries: &[Entry], @@ -150,3 +162,17 @@ fn build( } } } + +#[cfg(test)] +mod tests { + use super::refresh_due; + + /// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`. + #[test] + fn the_first_poll_refreshes_immediately() { + assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll"); + assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh"); + assert!(!refresh_due(299.9, 0.0)); + assert!(refresh_due(300.0, 0.0)); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3bb9f870d..391bf1ca9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -362,6 +362,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { Ok(opened(s, i, false)) } +/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error. fn run_close(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active); if i >= s.docs.len() { @@ -376,13 +377,16 @@ fn run_close(s: &mut Session, p: &Value) -> Result { if s.active >= s.docs.len() { s.active = s.docs.len().saturating_sub(1); } + s.bump_doc_epoch(); ok() } +/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch. fn run_closeall(s: &mut Session, _p: &Value) -> Result { s.cancel(); s.docs.clear(); s.active = 0; + s.bump_doc_epoch(); ok() } @@ -487,6 +491,7 @@ fn drawing_version(s: &Session, path: &str) -> Version { if is_dxf(path) { v } else { v.max(Version::R2004) } } +/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity). fn run_switch(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize; if i >= s.docs.len() { @@ -494,6 +499,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result { } s.cancel(); s.active = i; + // Bump even when `i` is already active. This is intentionally conservative: the command + // cancels the active command, and a screenshot client can only see observed state, not + // whether the switch was a no-op. Rejecting an outstanding capture is safer than returning + // an image whose queueing identity the client cannot distinguish from a real switch. + s.bump_doc_epoch(); ok() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e37aadbdd..c1eb5a6cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -647,6 +647,9 @@ impl Default for MLeaderOptions { pub struct Session { pub docs: Vec, pub active: usize, + /// Monotonic count of changes to the open drawings and the active one. A screenshot queued + /// across a switch is rejected even when a later switch returns to the same drawing. + pub doc_epoch: u64, pub settings: Settings, pub running: Option, /// A script is running ([`Session::script`]); a SCRIPT command inside it is refused. @@ -727,6 +730,7 @@ impl Session { Session { docs: Vec::new(), active: 0, + doc_epoch: 0, settings: Settings::default(), running: None, in_script: false, @@ -759,6 +763,7 @@ impl Session { hatch_picks: None, } } + /// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index. pub fn new_drawing(&mut self, metric: bool) -> usize { self.untitled_counter += 1; let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() }; @@ -767,8 +772,10 @@ impl Session { self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; + self.bump_doc_epoch(); self.active } + /// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index. pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option) -> usize { let mut st = DocState::new(d, title, path); let ext = st.doc.extents(&Space::Model); @@ -781,8 +788,14 @@ impl Session { self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; + self.bump_doc_epoch(); self.active } + /// Record that which drawing is open or active changed (screenshot binding). + pub fn bump_doc_epoch(&mut self) { + self.doc_epoch = self.doc_epoch.wrapping_add(1); + } + /// A command still waiting for input belongs to the drawing it started in: end it there /// before another drawing becomes current, so its undo step lands in that drawing. fn end_running(&mut self) { @@ -800,6 +813,7 @@ impl Session { Err(_) => { self.docs.truncate(n); self.active = self.active.min(self.docs.len().saturating_sub(1)); + self.bump_doc_epoch(); Err(EngineError::Internal("open".into(), "panic".into())) } } diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 79d28737e..1aee1b9db 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -86,8 +86,10 @@ const GRAY: Color = Color::Index(8); fn exports_both_drawings_as_colored_groups_in_a_new_drawing() { let mut s = compared(); let before = s.docs.len(); + let before_epoch = s.doc_epoch; let source = s.doc().unwrap().clone(); let r = s.execute("compareexport", &json!({})).unwrap(); + assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures"); assert_eq!(s.docs.len(), before + 1, "{r}"); assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}"); assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg"); diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index dafabdc7c..0f13b9bd3 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -8,7 +8,6 @@ use std::ops::Range; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::Arc; use cadcraft_geom::Vec2; @@ -183,9 +182,9 @@ struct Glyph { /// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order. fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec { let run = cs.get(r.clone()).unwrap_or_default(); - // Shares the face's bytes instead of copying them. - let bytes: Arc + Send + Sync> = face.bytes.clone(); - let Some(font) = harfrust::Font::new(bytes, face.index) else { return Vec::new() }; + // Keep the shared font bytes: shaping a run must not clone the entire font file. + let source = harfrust::font::Blob::Shared(face.bytes.clone()); + let Some(font) = harfrust::Font::new(source, face.index) else { return Vec::new() }; let shaper = harfrust::ShaperFont::new(&font); let mut buf = harfrust::Buffer::new(); let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}')); @@ -200,11 +199,10 @@ fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec } buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight }); buf.guess_segment_properties(); - // Without a plan, shaping fails only on an already shaped buffer or an unset direction. if harfrust::shape(&shaper, &mut buf, harfrust::ShapeOptions::new()).is_err() { return Vec::new(); } - let k = 1000.0 / f64::from(font.units_per_em().max(1)); + let k = 1000.0 / f64::from(shaper.units_per_em().max(1)); buf.glyph_infos() .iter() .zip(buf.glyph_positions()) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 18721a963..12abfdd99 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,8 +25,11 @@ serde_json = { workspace = true } log = { workspace = true } chrono = { workspace = true } image = { workspace = true } +web-time = { workspace = true } [dev-dependencies] +# The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in +# production too; the tests exercise the same installed chain as the app. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } diff --git a/crates/ui-egui/src/aliasdlg.rs b/crates/ui-egui/src/aliasdlg.rs index e643edcfb..6caded5f8 100644 --- a/crates/ui-egui/src/aliasdlg.rs +++ b/crates/ui-egui/src/aliasdlg.rs @@ -143,6 +143,7 @@ pub fn apply(app: &mut CadApp) -> bool { } } +/// 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() { crate::aliasdlg::open(app); @@ -151,7 +152,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let warn = Tokens::get().warn; let (mut ok, mut cancel, mut apply_clicked) = (false, false, false); let Some(st) = app.alias_dlg.as_mut() else { return }; - let id = egui::Id::new("aliasedit_dialog"); + let id = egui::Id::unique("aliasedit_dialog"); if std::mem::take(&mut st.raise) { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); } @@ -161,8 +162,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")), }; ui.horizontal(|ui| { - ui.label(crate::tl!("Filter")); - ui.text_edit_singleline(&mut st.filter); + let label = ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut st.filter).labelled_by(label.id); }); let f = st.filter.trim().to_ascii_lowercase(); let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect(); @@ -226,11 +227,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if let Some(form) = st.form.as_mut() { ui.group(|ui| { egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| { - ui.label(crate::tl!("Alias")); - ui.text_edit_singleline(&mut form.alias); + let alias_label = ui.label(crate::tl!("Alias")); + ui.text_edit_singleline(&mut form.alias).labelled_by(alias_label.id); ui.end_row(); - ui.label(crate::tl!("Command")); - ui.text_edit_singleline(&mut form.command); + let command_label = ui.label(crate::tl!("Command")); + ui.text_edit_singleline(&mut form.command).labelled_by(command_label.id); ui.end_row(); }); // A few commands starting with what was typed, to pick from. diff --git a/crates/ui-egui/src/animation.rs b/crates/ui-egui/src/animation.rs index 75e28a706..3e2f3ad78 100644 --- a/crates/ui-egui/src/animation.rs +++ b/crates/ui-egui/src/animation.rs @@ -75,7 +75,7 @@ struct Wheel { fn wheel(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> Wheel { let (mut scroll, mut zoom_points) = (egui::Vec2::ZERO, 0.0); for e in events { - let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } = e else { continue }; + let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers, .. } = e else { continue }; let per = match unit { egui::MouseWheelUnit::Point => 1.0, egui::MouseWheelUnit::Line => opts.line_scroll_speed, @@ -142,7 +142,7 @@ mod tests { use super::*; fn notch(unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } } #[test] @@ -194,7 +194,13 @@ mod tests { assert_eq!(zoomed.scroll, egui::Vec2::ZERO); assert!((zoomed.zoom - (o.scroll_zoom_speed * o.line_scroll_speed).exp()).abs() < 1e-6 && zoomed.zoom > 1.0); // A touchpad's begin and end markers carry no scroll. - let ended = egui::Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 9.0), phase: egui::TouchPhase::End, modifiers: none }; + let ended = egui::Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, 9.0), + phase: egui::TouchPhase::End, + source: egui::MouseWheelSource::Unknown, + modifiers: none, + }; assert_eq!(wheel(&[ended], &o, 500.0), Wheel { scroll: egui::Vec2::ZERO, zoom: 1.0 }); // Hostile deltas scroll and zoom nothing. for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index a0db8d0d7..e63bf1cbf 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -150,7 +150,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut open = None; let mut discard = None; let mut later = false; - egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| { ui.set_width(460.0); ui.label(RichText::new("Drawing Recovery").strong()); ui.add_space(4.0); @@ -204,7 +204,7 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) { let t = Tokens::get(); let mut dismiss = false; egui::Window::new("Graphics error") - .id(egui::Id::new("cc_gpu_notice")) + .id(egui::Id::unique("cc_gpu_notice")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index 0166e72ae..6137d5ccc 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -51,7 +51,7 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) .unwrap_or_else(|| crate::i18n::t("This drawing").to_string()); let mut choice: Option<&str> = None; egui::Window::new(crate::tl!("Open in Block Editor")) - .id(egui::Id::new("Open in Block Editor")) + .id(egui::Id::unique("Open in Block Editor")) .open(open) .collapsible(false) .resizable(false) @@ -165,15 +165,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default(); let (mut ok, mut cancel) = (false, false); egui::Window::new(crate::tl!("Edit Block Definition")) - .id(egui::Id::new("Edit Block Definition")) + .id(egui::Id::unique("Edit Block Definition")) .open(open) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { - ui.label(crate::tl!("Block to create or edit")); - let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)); + let name_label = ui.label(crate::tl!("Block to create or edit")); + let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)).labelled_by(name_label.id); if edit.changed() { app.bedit.error = None; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 390c40a1f..81c79bbf8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1362,7 +1362,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // draws the others. let tiles = tile_rects(app, full); let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r); - let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag()); + let resp = ui.interact(full, ui.scope_id().with("canvas"), Sense::click_and_drag()); let painter = ui.painter_at(rect); app.canvas.rect = Some(rect); app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0)); @@ -2846,7 +2846,7 @@ pub(crate) mod tests { } fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } } /// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways diff --git a/crates/ui-egui/src/canvas/blockedit.rs b/crates/ui-egui/src/canvas/blockedit.rs index d30d23140..e8d981b74 100644 --- a/crates/ui-egui/src/canvas/blockedit.rs +++ b/crates/ui-egui/src/canvas/blockedit.rs @@ -11,7 +11,7 @@ use crate::CadApp; use crate::theme::Tokens; fn id() -> egui::Id { - egui::Id::new("cadcraft-block-editor-bar") + egui::Id::unique("cadcraft-block-editor-bar") } /// The block the Block Editor is editing in the drawing shown, and whether it has unsaved changes. diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index 0f1ef4bba..c545a1d28 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -26,7 +26,7 @@ struct Cache { } fn id() -> egui::Id { - egui::Id::new("cadcraft-compare-overlay") + egui::Id::unique("cadcraft-compare-overlay") } /// Draw the comparison over the canvas when it was made for the drawing and tab shown. diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 000000000..4bca367b4 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1046 @@ +//! Bounded, one-shot window screenshots for `ui.screenshot`. +//! +//! The request record owns the output path, reply, deadline, document/focus identity and the +//! geometry it was admitted against. The shared `craft_ui::capture::CaptureSlot` owns the strong +//! `egui::Context`; its screenshot callback holds only a weak reference and never writes a file. +//! Files are encoded and written only from the logic drain, after the deadline and every identity +//! check have passed. + +use std::sync::mpsc::Sender; + +use craft_ui::capture::CaptureSlot; +use egui::ViewportCommand; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::CadApp; + +/// At most this many queued, in-flight or completed-but-not-yet-drained requests. +pub(crate) const MAX_REQUESTS: usize = 4; +/// CADCraft keeps its stricter 64 MiB per-image bound (the suite default is 128 MiB). +pub(crate) const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; +/// CADCraft keeps the aggregate reservation at the same 64 MiB bound. Completed but undrained +/// images still own their reservation, so they count against this limit. +pub(crate) const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + +const READY_DELAY: Duration = Duration::from_millis(120); +/// Accepted settled passes (never discarded passes or hidden logic ticks). +const SETTLE_PASSES: u32 = 3; +const DEADLINE: Duration = Duration::from_secs(8); +const REBUILD_WAIT: Duration = Duration::from_secs(6); +const REPAINT_INTERVAL: Duration = Duration::from_millis(16); + +/// The host surface as reported by one `RawInput`. `RawInput` is the fresh source on hidden +/// logic-only ticks; `Context::input`/`viewport_rect` describe the last UI pass and are stale +/// there. +/// +/// The equality used for the capture epoch is deliberately strict: OS focus and visibility +/// (`window_focused`, `minimized`, `occluded`) are part of it even though they are not geometry. +/// A focus or visibility transition can change what the presented frame contains (menu state, +/// active-window styling, a hidden surface), so a capture admitted before it is rejected rather +/// than risking an image from another epoch. Do not narrow this to size/DPI/zoom without a +/// replacement visibility check and a measured acceptance path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SurfaceGeometry { + /// Logical content size in points. A minimized window keeps its last known size so a forced + /// hidden paint can still be requested; `None` only before any size was ever reported. + viewport: Option, + /// Native pixels per point times egui's interface zoom. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, + window_focused: bool, +} + +impl SurfaceGeometry { + /// `Some(size)` only when both axes are finite and strictly positive; rejects degenerate viewport sizes before they enter a `SurfaceGeometry` snapshot. + fn valid_size(size: egui::Vec2) -> Option { + (size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size) + } + + /// The fresh raw hook's surface. Called from `raw_input_hook`, so it runs on both UI and + /// hidden logic-only callbacks and never reads the stale `InputState` rectangle. + fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + // `InputState::viewport_rect` is `RawInput::screen_rect`, falling back to the previous + // pass's rect; the pass snapshot uses the same source so the two agree bit for bit. The + // screenshot is of the UI content rect (what kittest renders and the plan binds to), so + // remove the safe-area insets here as `InputState::content_rect` does. + let insets = raw.safe_area_insets.unwrap_or_else(|| ctx.input(|input| input.safe_area_insets())); + let rect = raw.screen_rect.map(|rect| rect - insets); + let viewport = rect.and_then(|rect| Self::valid_size(rect.size())).or_else(|| previous.and_then(|previous| previous.viewport)); + let zoom = ctx.zoom_factor(); + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let pixels_per_point = info + .native_pixels_per_point + .map(|native| native * zoom) + .filter(|pixels| pixels.is_finite() && *pixels > 0.0) + .or_else(|| previous.map(|previous| previous.pixels_per_point)) + .unwrap_or_else(|| ctx.pixels_per_point()); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side)); + let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused: raw.focused }) + } + + /// The settled surface of a UI pass: the content rect and ppp this pass renders (and is + /// presented) with. The saved interface zoom takes effect at the start of a pass, so this is + /// the only scale the presented frame can be expected to have. + fn from_pass(ctx: &egui::Context, previous: Option) -> Option { + let content = ctx.input(|input| input.content_rect().size()); + let viewport = Self::valid_size(content).or_else(|| previous.and_then(|previous| previous.viewport)); + let pixels_per_point = ctx.pixels_per_point(); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = ctx.input(|input| input.max_texture_side); + let minimized = ctx.input(|input| input.viewport().minimized).unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = ctx.input(|input| input.viewport().occluded).unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + let window_focused = ctx.input(|input| input.focused); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused }) + } + + /// The pixel dimensions this surface would capture at: viewport points × pixels-per-point, rounded, each axis clamped to `max_texture_side` (0 means unlimited). + /// `None` when the viewport is unknown/non-finite or an axis overflows `usize`. + fn expected_size(self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round(); + if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 { + return None; + } + Some((pixels as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + /// Raw `Color32` bytes for `expected_size()` (4 bytes per pixel), with checked multiplication; `None` on overflow. + fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(std::mem::size_of::()) + } +} + +/// The immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureIdentity { + document: Option, + /// Monotonic count of drawing switches in the engine, so A→B→A is still a change. + document_epoch: u64, + focus: Option, +} + +/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + identity: CaptureIdentity, +} + +impl CaptureGeometry { + /// Pixel dimensions of the bound surface, or `None` when it cannot be estimated. + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + /// Raw image byte count of the bound surface, or `None` when it cannot be estimated. + fn expected_bytes(self) -> Option { + self.surface.expected_bytes() + } +} + +struct Shot { + token: u64, + path: Option, + reply: Sender, + deadline: Instant, + ready_at: Instant, + frames: u32, + issued: bool, + identity: CaptureIdentity, + identity_epoch: u64, + /// The surface epoch at issue; a later change invalidates the in-flight image. + surface_epoch: u64, + geometry: SurfaceGeometry, + expected: [usize; 2], + reserved: usize, + slot: Option, + callback: Option, +} + +/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained +/// captures; nothing is removed before it has been replied to or timed out. +#[derive(Default)] +pub(crate) struct Shots { + token: u64, + requests: Vec, +} + +impl Shots { + /// True when no queued, in-flight or completed-but-undrained request remains. + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + /// True when at least one admitted request has not been issued yet; drives the hidden-window repaint request. + pub(crate) fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + /// Saturating sum of the byte reservations of all outstanding requests. + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } + + /// Test-only count of outstanding request records. + #[cfg(test)] + fn len(&self) -> usize { + self.requests.len() + } +} + +/// Advance the per-app request token, wrapping and skipping 0. +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +/// The JSON rejection envelope `{ok:false,error,request}` used by admission, issuance, drain and cancellation rejections; `control::save_screenshot` builds an identical envelope for its own failures. +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +/// Send an `error_reply` to `reply`; a closed receiver is ignored so admission never blocks. +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +/// UID of the active drawing, or `None` when no drawing is open. +fn active_document(app: &CadApp) -> Option { + app.session.docs.get(app.session.active).map(|doc| doc.uid) +} + +/// The widget egui currently reports as keyboard-focused, if any. +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +/// Snapshot the active drawing UID, the monotonic document epoch and the focused widget together. +fn observed_identity(app: &CadApp, ctx: &egui::Context) -> CaptureIdentity { + CaptureIdentity { document: active_document(app), document_epoch: app.session.doc_epoch, focus: current_focus(ctx) } +} + +/// Store a fresh observation. The surface and identity epochs are independent: a surface change +/// re-baselines a queued request at issue, while any identity change rejects it, including one +/// that returns to the same document or focused widget. +fn observe(app: &mut CadApp, surface: SurfaceGeometry, identity: CaptureIdentity) { + let previous = app.capture_geometry; + let surface_changed = previous.is_none_or(|previous| previous.surface != surface); + let identity_changed = previous.is_none_or(|previous| previous.identity != identity); + if surface_changed || identity_changed { + app.capture_geometry = Some(CaptureGeometry { surface, identity }); + } + if surface_changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + if identity_changed { + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } +} + +/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs +/// on both UI and hidden logic-only callbacks. +pub(crate) fn note_raw_geometry(app: &mut CadApp, ctx: &egui::Context, raw: &egui::RawInput) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh the settled geometry of a UI pass (content rect and ppp), from every pass so a pass +/// that applies the saved interface zoom is the one admission and issuance use. +/// +/// An unusable pass snapshot invalidates the retained surface exactly like the raw path: the +/// previous geometry must not survive a pass whose pixels-per-point is invalid, or a later +/// admission could be bound to a surface no current pass renders. Both epochs grow so queued and +/// in-flight captures are re-based or rejected. +pub(crate) fn note_pass_geometry(app: &mut CadApp, ctx: &egui::Context) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_pass(ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh only the binding identity. Call after anything that can change the drawing or the +/// keyboard focus within one callback (each control command, opening completion), so an +/// intervening A→B→A transition is still seen. +pub(crate) fn note_identity(app: &mut CadApp, ctx: &egui::Context) { + let Some(geometry) = app.capture_geometry else { return }; + let identity = observed_identity(app, ctx); + observe(app, geometry.surface, identity); +} + +/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and +/// completed-but-undrained requests and reserves the expected raw image bytes before any capture. +/// The reservation is the admission estimate; issuance re-bases it on the settled pass. +pub(crate) fn admit(app: &mut CadApp, ctx: &egui::Context, path: Option, reply: Sender) { + note_identity(app, ctx); + let token = next_token(&mut app.shots); + let Some(geometry) = app.capture_geometry else { + return reject(reply, token, "screenshot failed: the window geometry is not known yet"); + }; + let Some(expected) = geometry.expected_size() else { + return reject(reply, token, "screenshot failed: the window has no capturable size yet"); + }; + let Some(reserved) = geometry.expected_bytes() else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if reserved > MAX_IMAGE_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + } + if app.shots.requests.len() >= MAX_REQUESTS { + return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding")); + } + let total = app.shots.reserved().saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + ); + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let now = Instant::now(); + app.shots.requests.push(Shot { + token, + path, + reply, + deadline: now + DEADLINE, + ready_at: now + READY_DELAY, + frames: 0, + issued: false, + identity: geometry.identity, + identity_epoch: app.capture_identity_epoch, + surface_epoch: app.capture_epoch, + geometry: geometry.surface, + expected, + reserved, + slot: Some(slot), + callback: Some(callback), + }); + ctx.request_repaint(); +} + +/// Human-readable reason when the live document, focus or document epoch differs from the shot's admission identity; `None` while all still match. +fn identity_reason(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if active_document(app) != shot.identity.document { + Some("the document changed while the capture was queued") + } else if current_focus(ctx) != shot.identity.focus { + Some("the keyboard focus changed while the capture was queued") + } else if app.session.doc_epoch != shot.identity.document_epoch { + Some("the drawing changed while the capture was queued") + } else { + None + } +} + +/// Any observed identity change since admission, including a transition that returned to the +/// same document or widget (`capture_identity_epoch` only ever grows). +fn identity_change(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if app.capture_identity_epoch == shot.identity_epoch { + return None; + } + Some(identity_reason(app, ctx, shot).unwrap_or("the drawing or keyboard focus changed while the capture was queued")) +} + +/// Issue settled requests from the first non-discarded UI pass. +/// +/// egui discards a pass only when `will_discard()` says so, and the final pass of a multi-pass +/// frame reports false; a hidden `run_logic` tick never reaches `issue` at all. +pub(crate) fn issue(app: &mut CadApp, ctx: &egui::Context) { + if ctx.will_discard() { + return; + } + // The draw may have moved the focus or switched drawings: observe that before judging. + note_identity(app, ctx); + let now = Instant::now(); + let building = app.canvas.building(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..app.shots.requests.len() { + if app.shots.requests[i].issued { + continue; + } + app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); + let shot = &app.shots.requests[i]; + if now < shot.ready_at || shot.frames < SETTLE_PASSES || (building && now < shot.ready_at + REBUILD_WAIT) { + continue; + } + if let Some(reason) = identity_change(app, ctx, shot) { + failures.push((i, reason.to_string())); + continue; + } + // Re-base the expected image on the settled pass. The admission estimate can predate the + // saved interface zoom, which egui applies at the start of a later pass. + let Some(current) = app.capture_geometry else { + failures.push((i, "screenshot failed: the window geometry is no longer known".into())); + continue; + }; + let Some(expected) = current.expected_size() else { + failures.push((i, "screenshot failed: the window has no capturable size".into())); + continue; + }; + let Some(reserved) = current.expected_bytes() else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if reserved > MAX_IMAGE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + )); + continue; + } + let total = app.shots.reserved().saturating_sub(shot.reserved).saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + failures.push(( + i, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + )); + continue; + } + let shot = &mut app.shots.requests[i]; + let Some(callback) = shot.callback.take() else { + failures.push((i, "screenshot failed: the capture callback was already consumed".into())); + continue; + }; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + shot.identity = current.identity; + shot.geometry = current.surface; + shot.expected = expected; + shot.reserved = reserved; + shot.surface_epoch = app.capture_epoch; + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Drain completed slots and expire timed-out requests. Call from every logic callback (including +/// hidden ones). The deadline is checked before taking or writing an image. +pub(crate) fn drain(app: &mut CadApp, ctx: &egui::Context) { + note_identity(app, ctx); + let mut i = 0; + while i < app.shots.requests.len() { + let now = Instant::now(); + if now >= app.shots.requests[i].deadline { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); + continue; + } + if let Some(reason) = identity_change(app, ctx, &app.shots.requests[i]) { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); + continue; + } + if !app.shots.requests[i].issued { + // A queued request re-bases its geometry on the settled pass at issue. + i += 1; + continue; + } + if app.capture_epoch != app.shots.requests[i].surface_epoch + || app.capture_geometry.map(|geometry| geometry.surface) != Some(app.shots.requests[i].geometry) + { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was queued"); + continue; + } + let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { + i += 1; + continue; + }; + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + match result { + Ok(image) => { + let reply = if image.size != shot.expected { + error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ) + } else { + crate::control::save_screenshot(&image, shot.path.as_deref(), shot.token) + }; + let _ = shot.reply.send(reply); + } + Err(error) => { + let _ = shot.reply.send(error_reply(shot.token, error)); + } + } + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write +/// nothing; clients receive one error reply instead of hanging past the transport timeout. +pub(crate) fn cancel_all(app: &mut CadApp) { + for shot in app.shots.requests.drain(..) { + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing")); + } +} + +/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a +/// capture is waiting for settlement. Native eframe honours `RequestPaintWhileHidden`; the web +/// runner currently logs it as unhandled, so web only schedules a repaint and hidden web captures +/// still reach their timeout (a documented limitation, not a success path). +pub(crate) fn request_hidden_paint(app: &CadApp, ctx: &egui::Context) { + if app.shots.has_unissued() { + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use cadcraft_engine::Session; + use egui::{Color32, ColorImage, ViewportId, ViewportInfo}; + use serde_json::json; + use std::sync::mpsc::{Receiver, channel}; + + use crate::Services; + + /// An empty `CadApp` over a fresh `Session` for capture tests. + fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) + } + + /// A visible, focused, 1-ppp surface of `size` with an 8192-pixel texture side. + fn surface(size: egui::Vec2) -> SurfaceGeometry { + SurfaceGeometry { + viewport: Some(size), + pixels_per_point: 1.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + window_focused: true, + } + } + + /// Seed the admission snapshot a raw hook would have taken, then bump both epochs as an + /// observation of a change would. + fn set_geometry(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) { + app.capture_geometry = Some(CaptureGeometry { surface: surface(size), identity: observed_identity(app, ctx) }); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + + /// Seed 800×600 geometry, admit one pathless request and return its reply receiver and token. + fn admit_one(app: &mut CadApp, ctx: &egui::Context) -> (Receiver, u64) { + set_geometry(app, ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + /// Focused `RawInput` with a `width`×`height` screen rect and an 8192-pixel texture side. + fn raw(width: f32, height: f32) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height))), + focused: true, + max_texture_side: Some(8192), + ..Default::default() + } + } + + /// One real UI pass, with a presented frame the screenshot commands can be taken from. + fn ui_frame(app: &mut CadApp, ctx: &egui::Context) -> egui::FullOutput { + ui_frame_sized(app, ctx, 800.0, 600.0) + } + + /// Run one real `logic` + `ui` pass at the requested size and drop texture deltas, yielding the frame's output. + fn ui_frame_sized(app: &mut CadApp, ctx: &egui::Context, width: f32, height: f32) -> egui::FullOutput { + let raw = raw(width, height); + note_raw_geometry(app, ctx, &raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + /// Every `ViewportCommand::Screenshot` callback emitted by a frame. + fn screenshot_callbacks(out: &egui::FullOutput) -> Vec { + out.viewport_output + .values() + .flat_map(|viewport| viewport.commands.iter()) + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect() + } + + /// Drive the settling passes so `issue` sends the real `ViewportCommand::Screenshot`. + fn issue_after_settling(app: &mut CadApp, ctx: &egui::Context) -> (egui::FullOutput, Vec) { + for shot in &mut app.shots.requests { + // Exercise the settled-frame path without sleeping for either wall-clock delay. + // A scale change can also start an asynchronous canvas rebuild. + shot.ready_at = Instant::now() - REBUILD_WAIT - Duration::from_millis(1); + } + ui_frame(app, ctx).drop_without_applying_deltas(); + ui_frame(app, ctx).drop_without_applying_deltas(); + let out = ui_frame(app, ctx); + let callbacks = screenshot_callbacks(&out); + (out, callbacks) + } + + /// Complete each screenshot callback with a solid red image of `size`. + fn complete(callbacks: Vec, size: [usize; 2]) { + let image = Arc::new(ColorImage::filled(size, Color32::RED)); + for callback in callbacks { + callback.complete(Arc::clone(&image)); + } + } + + /// A completed-but-undrained shot still occupies a `MAX_REQUESTS` slot; the next admission is rejected as "outstanding" and no earlier token is replaced. + #[test] + fn admission_counts_completed_but_undrained_and_never_evicts() { + let ctx = egui::Context::default(); + let mut app = app(); + let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect(); + assert_eq!(app.shots.len(), MAX_REQUESTS); + // Complete the first slot without draining it: it still owns its reservation and request. + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); + assert!(app.shots.requests.iter().all(|shot| shot.token != reply["request"].as_u64().unwrap_or_default())); + } + + /// A completed callback whose deadline has passed is rejected: the reply reports "timeout" and the request is removed from `Shots`; the test does not inspect the filesystem. + #[test] + fn deadline_is_checked_before_writing_a_completed_image() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.shots.requests[0].issued = true; + app.shots.requests[0].surface_epoch = app.capture_epoch; + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// A drawing switch before issue rejects the queued request as "document changed" and empties the queue. + #[test] + fn document_change_cancels_before_issuing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// Moving keyboard focus before issue rejects the queued capture as "focus changed" and empties the queue. + #[test] + fn focus_change_cancels_an_unissued_capture() { + let ctx = egui::Context::default(); + let focus_a = egui::Id::unique("focus-a"); + let focus_b = egui::Id::unique("focus-b"); + ctx.memory_mut(|memory| memory.request_focus(focus_a)); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), 1); + ctx.memory_mut(|memory| memory.request_focus(focus_b)); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a focus-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("focus changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature. + #[test] + fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx1, rx1) = channel(); + let (tx2, rx2) = channel(); + admit(&mut app, &ctx, None, tx1); + admit(&mut app, &ctx, None, tx2); + let tokens: Vec<_> = app.shots.requests.iter().map(|shot| shot.token).collect(); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 2, "both requests issue in the same settled frame"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + let first = rx1.recv().expect("the first reply"); + let second = rx2.recv().expect("the second reply"); + assert_eq!((first["ok"].clone(), second["ok"].clone()), (json!(true), json!(true))); + assert_eq!(first["request"].as_u64(), tokens.first().copied()); + assert_eq!(second["request"].as_u64(), tokens.get(1).copied()); + let a = first["result"]["path"].as_str().expect("the first file").to_string(); + let b = second["result"]["path"].as_str().expect("the second file").to_string(); + assert_ne!(a, b, "one file per request"); + for path in [&a, &b] { + assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}"); + let _ = std::fs::remove_file(path); + } + assert!(app.shots.is_empty()); + } + + /// A drawing switch while a capture is in flight rejects its completed callback as "document changed" and reports no result path. + #[test] + fn document_switch_after_issue_rejects_the_completed_callback_without_writing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + complete(callbacks, [800, 600]); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(reply.get("result").is_none(), "a rejected capture must not report or write a file"); + assert!(app.shots.is_empty()); + } + + /// Once a callback has completed but the deadline has passed, drain rejects it as "timeout" with no result path. + #[test] + fn completed_callback_after_timeout_writes_nothing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(reply.get("result").is_none(), "a timed-out capture must not write a file"); + assert!(app.shots.is_empty()); + } + + /// Cancelling all shots replies "cancelled" and wins over an already-completed callback, which is never reported. + #[test] + fn callback_completed_before_cancel_all_is_inert() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + cancel_all(&mut app); + let reply = rx.recv().expect("a cancellation reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("cancelled"), "{reply}"); + assert!(reply.get("result").is_none(), "cancellation wins over a late completion"); + assert!(app.shots.is_empty()); + } + + /// A 5000×5000 image is rejected by the 64 MiB per-image bound; after two 2500×2500 admissions a third is rejected by the aggregate bound, leaving two queued. + #[test] + fn per_image_and_aggregate_budgets_reject_before_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + // 5000×5000×4 bytes = 100 MB, over the 64 MiB per-image bound. + set_geometry(&mut app, &ctx, egui::vec2(5000.0, 5000.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("per-image rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("per-image"), "{reply}"); + assert!(app.shots.is_empty()); + + // 2500×2500×4 bytes = 25 MB each: two fit, the third exceeds the 64 MiB aggregate bound. + set_geometry(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _first = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _second = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("aggregate rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding requests"), "{reply}"); + assert_eq!(app.shots.len(), 2); + } + + /// Seed an arbitrary surface size, admit one pathless request and return the newly queued request's receiver and token. + fn admit_one_at(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) -> (Receiver, u64) { + set_geometry(app, ctx, size); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + /// Every observed surface change, including A→B→A, bumps the surface epoch and retains the latest geometry. + #[test] + fn every_observed_geometry_change_bumps_the_generation() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let first = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(900.0, 600.0)); + let second = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let third = app.capture_epoch; + assert!(first < second && second < third, "A→B→A bumps the generation at each observed change"); + assert_eq!(app.capture_geometry.and_then(|g| g.expected_size()), Some([800, 600])); + } + + /// An invalid settled pass must not leave the previous surface retained: the raw path clears + /// it, and the pass path has to invalidate it identically so a later admission cannot reuse a + /// surface no current pass renders. + #[test] + fn an_invalid_pass_invalidates_the_retained_surface() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + assert!(app.capture_geometry.is_some(), "the raw snapshot was retained"); + let surface_epoch = app.capture_epoch; + let identity_epoch = app.capture_identity_epoch; + let mut invalid = raw(800.0, 600.0); + invalid.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(0.0), ..Default::default() }); + let mut out = ctx.run_ui(invalid, |_| {}); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), 0.0, "the pass pixels-per-point is invalid"); + note_pass_geometry(&mut app, &ctx); + assert!(app.capture_geometry.is_none(), "an invalid pass must not keep the previous surface"); + assert!(app.capture_epoch > surface_epoch, "the surface epoch changes"); + assert!(app.capture_identity_epoch > identity_epoch, "the identity epoch changes with the surface"); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("an admission rejection"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("not known"), "{reply}"); + } + + /// With a screen rect but no inner rect, 640×480 points at 2 ppp yields a `[1280, 960]` image and keeps the focused flag. + #[test] + fn raw_geometry_uses_screen_rect_when_inner_rect_is_absent() { + let ctx = egui::Context::default(); + let mut app = app(); + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), + focused: true, + max_texture_side: Some(4096), + ..Default::default() + }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() }); + note_raw_geometry(&mut app, &ctx, &raw); + let geometry = app.capture_geometry.expect("fresh geometry"); + assert_eq!(geometry.expected_size(), Some([1280, 960])); + assert!(geometry.surface.window_focused); + } + + /// The producer regression for the pass-lifecycle defect: a first pass that egui discards must + /// not issue, and the settled second pass must issue exactly once. + #[test] + fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + shot.frames = SETTLE_PASSES - 1; + } + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let mut discard = true; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + let callbacks = screenshot_callbacks(&out); + assert_eq!(callbacks.len(), 1, "the settled pass issues once; the discarded pass does not"); + assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + assert_eq!(rx.recv().expect("a reply")["ok"], true); + } + + /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom + /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate. + #[test] + fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { + let ctx = egui::Context::default(); + let mut app = app(); + app.options.interface_scale = 2.0; + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].expected, [800, 600], "the admission estimate predates the zoom"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [1600, 1200], "issued against the settled pass scale"); + complete(callbacks, [1600, 1200]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } + + /// A→B→A: a switch that returns to the same drawing still invalidates the queued request. + #[test] + fn a_document_switch_that_returns_still_rejects_the_queued_capture() { + let ctx = egui::Context::default(); + let mut app = app(); + app.session.new_drawing(false); + let (rx, _) = admit_one(&mut app, &ctx); + let before = active_document(&app); + app.run("document.switch", json!({ "index": 0 })).expect("switch to the first drawing"); + note_identity(&mut app, &ctx); + app.run("document.switch", json!({ "index": 1 })).expect("switch back"); + note_identity(&mut app, &ctx); + assert_eq!(active_document(&app), before, "the drawing and focus are back where they were"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert!(callbacks.is_empty(), "the request is rejected, not issued"); + let reply = rx.recv().expect("a rejection reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("changed"), "{reply}"); + assert!(reply.get("result").is_none()); + } + + /// A drawing created and closed inside one callback never reaches the same observed values, but + /// the engine's monotonic document epoch still records it. + #[test] + fn a_drawing_created_and_closed_between_observations_still_bumps_the_identity_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let before_epoch = app.capture_identity_epoch; + let before_doc = active_document(&app); + app.session.new_drawing(false); + app.run("close", json!({})).expect("close the new drawing"); + assert_eq!(active_document(&app), before_doc, "the same drawing is active again"); + note_identity(&mut app, &ctx); + assert!(app.capture_identity_epoch > before_epoch, "the intervening switch is still observed"); + } + + /// Raw-hook and pass geometry must agree, or every frame would re-baseline the surface and a + /// capture in flight could never settle. + #[test] + fn a_settled_window_does_not_keep_changing_the_surface_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + let epoch = app.capture_epoch; + let identity = app.capture_identity_epoch; + for _ in 0..4 { + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + } + assert_eq!(app.capture_epoch, epoch, "the raw hook and the pass agree on the surface"); + assert_eq!(app.capture_identity_epoch, identity, "nothing changed the drawing or focus"); + assert_eq!(app.capture_geometry.and_then(|geometry| geometry.expected_size()), Some([800, 600])); + } + + /// A window resized while the request is still settling is issued against the new size. + #[test] + fn a_resize_while_settling_is_absorbed_at_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + } + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + let out = ui_frame_sized(&mut app, &ctx, 900.0, 600.0); + let callbacks = screenshot_callbacks(&out); + out.drop_without_applying_deltas(); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [900, 600], "issued against the settled size"); + complete(callbacks, [900, 600]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b30a0110f..893379f35 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -80,7 +80,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); // Allow dragging the window by the title row. let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h)); - let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG); + let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG); if resp.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -122,7 +122,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let Some(group) = TOOLBAR.get(*index) else { continue }; for (icon, cmd, label, shortcut) in group.iter() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size)); - let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("tb", *cmd)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } @@ -141,7 +141,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { } if !fit.overflow.is_empty() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size)); - let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click()); + let response = ui.interact(br, ui.scope_id().with("toolbar_more"), Sense::click()); if response.hovered() { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } @@ -184,7 +184,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { icons::paint(ui.painter(), ib.shrink(1.0), Icon::Switcher, false); x += 24.0; let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); - let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click()); + let presp = ui.interact(plus, ui.scope_id().with("tab+"), Sense::click()); icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false); icons::describe_control(ui, &presp, "New Drawing", None, false); if presp.on_hover_text(crate::tl!("New Drawing")).clicked() { @@ -238,7 +238,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { x += w; (resp.clicked(), close, resp) }; - let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start")); + let (c, _, _) = + tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.scope_id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -247,7 +248,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { let titles: Vec<(usize, String, bool, bool)> = app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect(); for (i, title, dirty, active) in titles { - let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i))); + let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.scope_id().with(("tab", i))); crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i)); if c { switch_to = Some(i); @@ -314,7 +315,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let mut list_button = None; for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); - let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); icons::describe_control(ui, &resp, tip, None, false); let resp = resp.on_hover_text(crate::i18n::t(tip)); @@ -368,7 +369,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let model = app.session.state().is_ok_and(|st| st.mspace.is_some()); let label = if model { "MODEL" } else { "PAPER" }; let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0)); - let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with("mspace_toggle"), Sense::click()); p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) }); p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text); icons::describe_control(ui, &resp, label, None, false); @@ -417,7 +418,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let w = g.size().x + 26.0; let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip); let active = active_tab == Some(i); - let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click()); + let resp = ui.interact(tr, ui.scope_id().with(("lt", i)), Sense::click()); if active { tp.rect_filled(tr, 2.0, t.tab_active); } else if resp.hovered() { @@ -433,7 +434,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { x += w + 2.0; if i == 0 { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); - let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); + let presp = ui.interact(pr, ui.scope_id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); icons::describe_control(ui, &presp, "New layout", None, false); if presp.on_hover_text(crate::tl!("New layout")).clicked() @@ -448,7 +449,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // The layouts that didn't fit, behind a button after the last tab shown. let more = hidden.then(|| { let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0)); - let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click()); + let resp = ui.interact(mr, ui.scope_id().with("layout_more"), Sense::click()); if resp.hovered() { p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5)); } @@ -489,13 +490,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom())); let text_x = if message.is_some() { left } else { coord_right - galley.size().x }; p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color); - ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text); + ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text); } } let mut toggle_cmd = None; for (icon, on, cmd, tip) in toggles { let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size)); - let resp = ui.interact(br, ui.id().with(("tg", tip)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click()); if on { p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35)); } else if resp.hovered() { @@ -541,7 +542,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let mut action = None; for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] { let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0)); - let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("start", cmd)), Sense::click()); p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { @@ -555,7 +556,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let mut y = main.top() + 44.0; for (i, path) in app.ui.recent_files.iter().enumerate() { let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0)); - let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click()); + let resp = ui.interact(row, ui.scope_id().with(("recent", i)), Sense::click()); if resp.hovered() { p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5)); } diff --git a/crates/ui-egui/src/chrome/annoscale.rs b/crates/ui-egui/src/chrome/annoscale.rs index 4aa9a2613..b18db6e19 100644 --- a/crates/ui-egui/src/chrome/annoscale.rs +++ b/crates/ui-egui/src/chrome/annoscale.rs @@ -22,7 +22,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let g = p.layout_no_wrap(current.clone(), crate::theme::small(), t.text); let w = g.size().x.min(160.0) + 22.0; let r = Rect::from_min_size(pos2(right - w, cy - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, ui.id().with("anno_scale"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("anno_scale"), Sense::click()); p.rect_filled(r, 3.0, if resp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) }); p.with_clip_rect(r.shrink2(vec2(4.0, 0.0))).galley(pos2(r.left() + 6.0, r.center().y - g.size().y / 2.0), g, t.text); let (ax, ay) = (r.right() - 9.0, r.center().y); diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs index af612aba7..69070f4fb 100644 --- a/crates/ui-egui/src/clipboard.rs +++ b/crates/ui-egui/src/clipboard.rs @@ -13,10 +13,10 @@ use crate::CadApp; /// egui memory: the clipboard copy last mirrored, and the hash of the text it became. fn serial_id() -> egui::Id { - egui::Id::new("cc_clipboard_serial") + egui::Id::unique("cc_clipboard_serial") } fn text_id() -> egui::Id { - egui::Id::new("cc_clipboard_text") + egui::Id::unique("cc_clipboard_text") } /// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised. diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index bd1d30148..9e4f35ed8 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { }; let t = Tokens::get(); let mut choice = None; - egui::Modal::new(egui::Id::new("cc_close_prompt")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("cc_close_prompt")).show(ctx, |ui| { ui.set_width(360.0); ui.label(RichText::new(crate::tl!("CADCraft")).strong()); ui.add_space(4.0); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index c136b7032..e9e0db8c7 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -395,6 +395,7 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { app.ui.history_lines.min(MAX_LINES).min(available) } +/// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0)); @@ -443,7 +444,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { for (i, k) in kws.iter().enumerate() { let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword); let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); - let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click()); if resp.hovered() { p.rect_filled(r, 2.0, t.cmd_keyword_hover); } @@ -523,7 +524,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); for (i, s) in sug.iter().enumerate() { let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh)); - let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("sug", i)), Sense::click()); if resp.hovered() || i == 0 { p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row }); } @@ -586,7 +587,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { /// the top edge sets how many lines show (`ui.cmdline.lines`). fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) { let z = zones(app, canvas, bar); - let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag()); + let resp = ui.interact(z.all, ui.scope_id().with("cmdline"), Sense::click_and_drag()); let at = resp.hover_pos().or(resp.interact_pointer_pos()); let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none(); let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p)); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 68f427679..bbaf48df3 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -313,8 +313,8 @@ struct Press(f64, egui::Pos2); /// the menu while it is open. pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool { let ctx = ui.ctx().clone(); - let press_id = egui::Id::new("cc_command_menu_press"); - let popup_id = egui::Id::new("cc_command_menu"); + let press_id = egui::Id::unique("cc_command_menu_press"); + let popup_id = egui::Id::unique("cc_command_menu"); let secondary = egui::PointerButton::Secondary; let (pressed, released, now, pos) = ctx.input(|i| (i.pointer.button_pressed(secondary), i.pointer.button_released(secondary), i.time, i.pointer.interact_pos())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b66c0919c..2dc1f898a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -102,8 +102,9 @@ struct Widget { selected: Option, } +/// egui temp-data key holding the widgets observed during the current frame. fn widgets_id() -> egui::Id { - egui::Id::new("cadcraft-observed-widgets") + egui::Id::unique("cadcraft-observed-widgets") } pub(crate) fn begin_frame(ctx: &egui::Context) { @@ -252,6 +253,7 @@ pub fn cmdline_state(app: &CadApp) -> Value { }) } +/// Build the `ui.inspect` snapshot: UI state and theme, animations, focus, canvas/window rects, view, cursor, snap, session summary, close prompt and performance counters. pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { let r = ctx.content_rect(); let view = app.session.state().map(|s| s.view()).ok(); @@ -267,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { "snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})), "session": app.session.summary(), "closePrompt": app.close_prompt(), - "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())}, + "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, }) } @@ -543,18 +545,138 @@ fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value { v } -/// Save a screenshot PNG. -pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { +/// A private, per-process screenshot directory for pathless `ui.screenshot` requests. +/// +/// The directory is created with owner-only permissions (`0700` on Unix) under a name that +/// includes the process id, a monotonic nonce and a sequence number. `create_dir` fails rather +/// than following a pre-created symlink, so another local user cannot redirect the write. +fn private_shot_dir() -> std::io::Result { + use std::sync::OnceLock; + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| create_private_shot_dir().map_err(|e| e.to_string())).clone().map_err(std::io::Error::other) +} + +/// Process-wide monotonic counter used to keep pathless screenshot names unique. +fn next_pathless_nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// Create at most 32 exclusive `cadcraft-shots---` directories in the temp dir; only `AlreadyExists` is retried. +fn create_private_shot_dir() -> std::io::Result { + let base = std::env::temp_dir(); + let stamp = crate::now_ms().to_bits(); + for _ in 0..32 { + let nonce = next_pathless_nonce(); + let dir = base.join(format!("cadcraft-shots-{}-{stamp:x}-{nonce}", std::process::id())); + match create_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not create a private screenshot directory")) +} + +/// Create `path` with mode 0700 on Unix (plain create elsewhere), failing rather than following or reusing a pre-created entry. +fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + std::fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(not(unix))] + { + std::fs::create_dir(path) + } +} + +/// Create a new file with owner-only permissions. `create_new` uses `O_EXCL` on Unix and refuses +/// an existing path, including a symlink, so a pre-created link cannot redirect the screenshot. +fn create_private_file(path: &std::path::Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options.open(path) +} + +/// True when the metadata has no group/other permission bits (always true on non-Unix). +fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o077 == 0 + } + #[cfg(not(unix))] + { + let _ = meta; + true + } +} + +/// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG. +fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result { + use std::io::Write as _; + let dir = private_shot_dir()?; + // Tests and users may remove the private directory between requests; recreate it exclusively + // rather than following anything that replaced it. + if !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_dir() && !m.file_type().is_symlink() && is_private_shot_dir(&m)) { + match create_private_dir(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let meta = std::fs::symlink_metadata(&dir)?; + if !meta.file_type().is_dir() || meta.file_type().is_symlink() || !is_private_shot_dir(&meta) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the screenshot directory was replaced")); + } + } + Err(e) => return Err(e), + } + } + for _ in 0..64 { + let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce())); + match create_private_file(&path) { + Ok(mut file) => { + img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?; + file.flush()?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique screenshot file")) +} + +/// Save a screenshot PNG. Only called from the logic drain, never from the screenshot callback. +/// +/// An explicit `path` is written as before. Without one, the file gets a unique name inside a +/// private per-process directory, created exclusively with owner-only permissions; concurrent +/// requests (even from two app instances with the same per-process token) cannot collide or +/// follow a pre-created symlink. +pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value { let [w, h] = image.size; - let mut buf = Vec::with_capacity(w * h * 4); + let Some(len) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else { + return json!({"ok": false, "error": "screenshot dimensions overflow", "request": token}); + }; + let mut buf = Vec::with_capacity(len); for c in &image.pixels { buf.extend_from_slice(&c.to_array()); } - let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) }; - let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("cadcraft-shot.png").to_string_lossy().to_string()); - match img.save(&path) { - Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), - Err(e) => json!({"ok": false, "error": e.to_string()}), + let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { + return json!({"ok": false, "error": "bad image", "request": 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()), + }; + match saved { + Ok(path) => json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e.to_string(), "request": token}), } } @@ -610,6 +732,55 @@ mod tests { assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true})); } + /// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700. + #[test] + fn pathless_screenshots_are_unique_private_png_files() { + let img = egui::ColorImage::filled([4, 3], egui::Color32::RED); + let first = save_screenshot(&img, None, 1); + let second = save_screenshot(&img, None, 1); + assert_eq!(first["ok"], true, "{first}"); + assert_eq!(second["ok"], true, "{second}"); + let a = std::path::PathBuf::from(first["result"]["path"].as_str().unwrap_or_default()); + let b = std::path::PathBuf::from(second["result"]["path"].as_str().unwrap_or_default()); + assert_ne!(a, b, "the same per-process token must not reuse a pathless file"); + for path in [&a, &b] { + let bytes = std::fs::read(path).expect("the screenshot file exists"); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?} is a PNG"); + image::load_from_memory(&bytes).expect("decodable PNG"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + for path in [&a, &b] { + let mode = std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "screenshot {path:?} is owner-only"); + } + let dir = a.parent().expect("the file has a private directory"); + let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only"); + } + for path in [&a, &b] { + let _ = std::fs::remove_file(path); + } + } + + /// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged. + #[cfg(unix)] + #[test] + fn private_screenshot_file_refuses_a_preexisting_symlink() { + let dir = std::env::temp_dir().join(format!("cadcraft-shot-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + create_private_dir(&dir).expect("private test directory"); + let target = dir.join("target.txt"); + std::fs::write(&target, b"do not overwrite").expect("target file"); + let link = dir.join("shot.png"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + let err = create_private_file(&link).expect_err("an existing symlink is refused"); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&target).expect("target unchanged"), b"do not overwrite"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn render_reply_has_base64_width_height_and_keeps_old_keys() { let r = render_reply(b"abc", 7, 5, Some("x.png")); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 2385c61b5..6c3276c91 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,7 +212,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::tl!("Show")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a46d7ed7..37de4f197 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -39,10 +39,11 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } } +/// Drafting Settings window: snap/grid modes and spacing, isometric snap, polar tracking and object snaps, editing the session settings directly. fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let settings = app.session.drafting_settings(); let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit); - egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::unique("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { let s = &mut app.session.settings; ui.heading(crate::tl!("Snap and Grid")); ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)")); @@ -52,10 +53,10 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap")); }); ui.horizontal(|ui| { - ui.label(crate::tl!("Snap X spacing")); - ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); - ui.label(crate::tl!("Y")); - ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); + let x_label = ui.label(crate::tl!("Snap X spacing")); + ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(x_label.id); + let y_label = ui.label(crate::tl!("Y")); + ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(y_label.id); }); // SNAPTYPE and POLARDIST. ui.horizontal(|ui| { @@ -65,16 +66,16 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); ui.add_enabled_ui(s.polarsnap, |ui| { ui.horizontal(|ui| { - ui.label(crate::tl!("Polar distance")); - ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)); + let label = ui.label(crate::tl!("Polar distance")); + ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)).labelled_by(label.id); }); }); ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)")); ui.horizontal(|ui| { - ui.label(crate::tl!("Grid spacing")); - ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)); - ui.label(crate::tl!("Major line every")); - ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)); + let grid_label = ui.label(crate::tl!("Grid spacing")); + ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)).labelled_by(grid_label.id); + let major_label = ui.label(crate::tl!("Major line every")); + ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)).labelled_by(major_label.id); }); ui.separator(); ui.heading(crate::tl!("Polar Tracking")); @@ -84,12 +85,13 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let current = (s.polarang.to_degrees() * 1e6).round() / 1e6; let mut deg = current; ui.horizontal(|ui| { - ui.label(crate::tl!("Increment angle")); - egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { + let label = ui.label(crate::tl!("Increment angle")); + let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] { ui.selectable_value(&mut deg, a, format!("{a}")); } }); + combo.response.labelled_by(label.id); }); if deg != current { s.polarang = deg.to_radians(); @@ -161,14 +163,15 @@ fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) { } let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0; ui.horizontal(|ui| { - if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() { + let additional_label = ui.checkbox(&mut additional, crate::tl!("Additional angles")); + if additional_label.changed() { s.polar_flags ^= polarmode::ADDITIONAL; } // The list as typed (degrees separated by `;`), kept while it doesn't parse yet. - let id = ui.id().with("polaraddang"); + let id = ui.scope_id().with("polaraddang"); let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang); let mut text = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); - let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)); + let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)).labelled_by(additional_label.id); if resp.changed() && let Some(angles) = cadcraft_engine::sysvars::parse_angle_list(&text) { @@ -250,7 +253,7 @@ fn length_precisions(lunits: i64) -> Vec<(i64, String)> { /// through the `units` command on OK. fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { use cadcraft_engine::units::{format_angle, format_distance}; - let id = egui::Id::new("units_dialog_values"); + let id = egui::Id::unique("units_dialog_values"); let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| { app.session .doc() @@ -314,11 +317,12 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// About window with About, Contributors and Models tabs, version, licence and project links. fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); egui::Window::new(crate::tl!("About CADCraft")) - .id(egui::Id::new("About CADCraft")) + .id(egui::Id::unique("About CADCraft")) .open(open) .default_size(vec2(640.0, 420.0)) .collapsible(false) @@ -351,16 +355,17 @@ fn about(ctx: &egui::Context, open: &mut bool) { }); } +/// Command Reference window; filters commands by id or label (English or translated) and starts the clicked command after closing the dialog. fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut start = None; - egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( + egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::unique("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( ctx, |ui| { - let id = ui.id().with("cmdfilter"); + let id = ui.scope_id().with("cmdfilter"); let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal(|ui| { - ui.label(crate::tl!("Filter")); - ui.text_edit_singleline(&mut filter); + let label = ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut filter).labelled_by(label.id); }); ui.data_mut(|d| d.insert_temp(id, filter.clone())); let f = filter.to_lowercase(); @@ -391,9 +396,10 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Blocks window listing named (non-anonymous) blocks; clicking one starts INSERT with that block name already supplied. fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut pick = None; - egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { + egui::Window::new(crate::tl!("Blocks")).id(egui::Id::unique("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { let Ok(d) = app.session.doc() else { return }; let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect(); if names.is_empty() { @@ -422,8 +428,9 @@ fn insert_block(app: &mut CadApp, name: &str) { } } +/// Interface Language combo; selecting a language runs `ui.language` with its code. fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| { egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| { for preference in crate::i18n::Preference::for_picker() { if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() { diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6b9cedbe9..6c1e624b5 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -213,6 +213,7 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result(ui.ctx(), egui::Id::new("cadcraft-docking")); + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("cadcraft-docking")); } publish_visibility(app, &workspace); app.ui.docking = workspace; diff --git a/crates/ui-egui/src/dyngrips.rs b/crates/ui-egui/src/dyngrips.rs index f38f19466..7c30447c4 100644 --- a/crates/ui-egui/src/dyngrips.rs +++ b/crates/ui-egui/src/dyngrips.rs @@ -131,7 +131,7 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) { let gs = app.session.settings.gripsize as f32; let at = xf.to_screen(g.at) + vec2(-gs, gs * 1.5); let mut chosen: Option = None; - let area = egui::Area::new(egui::Id::new(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { for c in &g.choices { if ui.selectable_label(c.eq_ignore_ascii_case(¤t), c.as_str()).clicked() { diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs index 7ece54259..48a615003 100644 --- a/crates/ui-egui/src/exchange.rs +++ b/crates/ui-egui/src/exchange.rs @@ -72,14 +72,14 @@ const FROM_PLOT: &str = "cc_plot_preview_from_plot"; /// return to the dialog. pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) { PREVIEW_GEN.fetch_add(1, Ordering::Relaxed); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info })); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FROM_PLOT), FromPlot { params, info })); app.ui.dialog = Some("preview".into()); } /// Render the plot preview of the current layout (or model), or of the Plot dialog's settings, /// as a texture. fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> { - if let Some(f) = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))) { + if let Some(f) = ctx.data(|d| d.get_temp::(egui::Id::unique(FROM_PLOT))) { let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?; let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?; let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8(); @@ -101,7 +101,7 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin /// The Plot Preview window: the sheet as it plots, with Print and Close. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - let id = egui::Id::new("cc_plot_preview"); + let id = egui::Id::unique("cc_plot_preview"); let generation = PREVIEW_GEN.load(Ordering::Relaxed); let cached = ctx.data(|d| d.get_temp::(id)).filter(|p| p.generation == generation); let preview = match cached { @@ -137,11 +137,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if close || print { *open = false; } - let from_plot = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))).is_some(); + let from_plot = ctx.data(|d| d.get_temp::(egui::Id::unique(FROM_PLOT))).is_some(); if !*open { ctx.data_mut(|d| { d.remove::(id); - d.remove::(egui::Id::new(FROM_PLOT)); + d.remove::(egui::Id::unique(FROM_PLOT)); }); } if from_plot && !*open { diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs index 6a0ad2206..1fff4c627 100644 --- a/crates/ui-egui/src/hatchdlg.rs +++ b/crates/ui-egui/src/hatchdlg.rs @@ -430,7 +430,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }; let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") }; let mut action = Action::None; - egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| { ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(340.0); @@ -478,6 +478,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Pattern/gradient fill selector and its controls; an origin pick is reported through `action`. fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { ui.horizontal(|ui| { ui.selectable_value(&mut f.gradient, false, crate::tl!("Pattern")); @@ -487,12 +488,12 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { if f.gradient { egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { ui.label(crate::tl!("Color 1")); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color1"), f.color1, false, 160.0) { f.color1 = c; } ui.end_row(); ui.label(crate::tl!("Color 2")); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color2"), f.color2, false, 160.0) { f.color2 = c; } ui.end_row(); @@ -853,7 +854,7 @@ mod tests { let (mut app, _) = app_with_rect(); open_create(&mut app, gradient); let ctx = frames(&mut app); - let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap(); + let r = ctx.memory(|m| m.area_rect(egui::Id::unique("hatch_dialog"))).unwrap(); // The screen is 1000 points high: the window keeps to its content. assert!(r.height() < 700.0, "gradient {gradient}: {r:?}"); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7044017d3..3bb2690a1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -323,8 +323,9 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { // Without a font for its script, a language stays chosen and the interface shows English. let shown = app.language(); assert_eq!(shown, if crate::theme::can_show(language.code) { language.code } else { "en" }); - assert_eq!(app.fonts_language, Some(shown)); let text = render(&mut app, &ctx); + // Fonts are a frame-only concern: the render above installs them. + assert_eq!(app.fonts_language, Some(shown)); for source in ["Properties", "File"] { assert!(text.contains(tr(shown, source)), "{}: {source}: {text}", language.code); } @@ -345,8 +346,8 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { let _ = crate::control::handle(&mut app, &ctx, &request); assert_eq!(app.ui.interface_language, Preference::parse(code).unwrap()); app.logic(&ctx); - assert_eq!(app.fonts_language, Some(app.language())); render(&mut app, &ctx); + assert_eq!(app.fonts_language, Some(app.language())); } assert!(render(&mut app, &ctx).contains("Properties")); // The menu tree used by the control channel keeps its original labels and IDs. @@ -409,9 +410,9 @@ fn interface_fonts_cover_every_letter_in_each_catalog() { for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { // In egui 0.36, has_glyph can report a false negative for glyphs in the same // face as the replacement character. Check the family's actual character maps. - let mut font = view.fonts.font(&family); + let chars = view.characters(&family); for c in &letters { - if !font.characters().contains_key(c) { + if !chars.contains_key(c) { missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c))); } } @@ -470,9 +471,9 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() { let missing = ctx.fonts_mut(|view| { let mut missing = Vec::new(); for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - let mut font = view.fonts.font(&family); + let chars = view.characters(&family); for c in "한국어".chars() { - if !font.characters().contains_key(&c) { + if !chars.contains_key(&c) { missing.push(format!("{family:?}: {c}")); } } @@ -481,3 +482,28 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() { }); assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", ")); } + +/// The production font chain covers alphanumeric ASCII in proportional and monospace, plus ⌘ ⇧ ↩ … − in proportional and ⇧ in monospace. +#[test] +fn production_font_chain_covers_latin_and_shortcut_symbols() { + // Exercise the same chain the app installs: the workspace egui dependency enables + // `default_fonts` and `monochrome_emoji_fonts` in production, so web and native builds + // always start with bounded Latin and monochrome icon faces instead of an empty family. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|view| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars() { + assert!(view.has_glyph(&egui::FontId::new(12.0, family.clone()), c), "Latin {c:?} missing in {family:?}"); + } + } + // Proportional shortcut labels (`Button::shortcut_text` and the translated menus) use + // these symbols: ⌘ from the monochrome icon face, ⇧ from Hack as a bounded fallback. + for c in ['\u{2318}', '\u{21e7}', '\u{21a9}', '\u{2026}', '\u{2212}'] { + assert!(view.has_glyph(&egui::FontId::proportional(12.0), c), "U+{:04X} missing in Proportional", c as u32); + } + assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace"); + }); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index df2e31184..1f018df2e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -683,7 +683,9 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { LayerFreeze => pen.text(x, y, "❄", 9.0, Color32::from_rgb(0x9f, 0xd4, 0xff)), LayerLock => pen.rect(x - 3.0, y - 1.0, 6.0, 5.0, Color32::from_rgb(0xe6, 0xb4, 0x3c)), LayerMatch => pen.la(x - 4.0, y, x + 4.0, y), - MakeCurrent => pen.text(x, y, "✓", 9.0, Color32::from_rgb(0x6f, 0xe0, 0x6f)), + // Drawn rather than a "✓" glyph: no bundled font has U+2713, and a missing + // glyph panics under epaint's strict policy when no system font supplies one. + MakeCurrent => pen.poly(&[(x - 3.0, y), (x - 1.0, y + 2.0), (x + 3.0, y - 2.5)], false, Color32::from_rgb(0x6f, 0xe0, 0x6f)), _ => { pen.arc(x, y, 3.5, 90.0, 360.0, a); } @@ -967,8 +969,8 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, selected: Option, tab: bool) { let label = crate::i18n::t(label); response.widget_info(|| match selected { - Some(selected) => egui::WidgetInfo::selected(egui::WidgetType::Button, response.enabled(), selected, label), - None => egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), label), + Some(selected) => egui::WidgetInfo::selected(egui::Role::Button, response.enabled(), selected, label), + None => egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), label), }); if tab { ui.ctx().accesskit_node_builder(response.id, |node| { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 6cadfeac1..d9bc0d833 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -218,7 +218,7 @@ fn natural_lower(s: &str) -> String { /// The Layer Properties Manager window. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("lpm_state"); + let sid = egui::Id::unique("lpm_state"); let mut st: LpmState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let mut actions: Vec<(&str, Value)> = Vec::new(); let Ok(dst) = app.session.state() else { @@ -249,7 +249,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { None => "Layer Properties Manager", }; egui::Window::new(crate::i18n::t(title)) - .id(egui::Id::new("lpm_window")) + .id(egui::Id::unique("lpm_window")) .open(open) .default_size(vec2(1180.0, 440.0)) .min_width(640.0) @@ -302,7 +302,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { { actions.push(("layerstate.delete", json!({ "name": s }))); } - ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)) + .on_hover_text(crate::tl!("New state name")); if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() { let n = st.state_name.trim().to_string(); actions.push(("layerstate.save", json!({ "name": n }))); @@ -310,7 +311,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { st.state_name.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)); + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)) + .on_hover_text(crate::tl!("Search for layer")); icons::paint( ui.painter(), Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), @@ -403,6 +405,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// One Layer Properties Manager row: status/name, on/freeze/lock/plot, colour, linetype, lineweight, transparency, viewport overrides, new-viewport freeze and description. +/// Edits update `st` and push commands into `actions`. #[allow(clippy::too_many_arguments)] fn row( ui: &mut egui::Ui, @@ -439,7 +443,9 @@ fn row( // Name (double-click or F2-style rename). match st.renaming.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::new(("lpm_rename", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name)))) + .accessible_name(crate::tl!("Name")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -503,7 +509,7 @@ fn row( actions.push(("layer.set", json!({ "name": name, "plot": !l.plot }))); } // Color. - if let Some(c) = color_button(ui, egui::Id::new(("lpm_color", &name)), l.color, false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_color", &name)), l.color, false, 96.0) { actions.push(("layer.set", json!({ "name": name, "color": c.name() }))); } // Linetype. @@ -524,7 +530,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(<_resp) - .id(egui::Id::new(("lpm_lt", &name))) + .id(egui::Id::unique(("lpm_lt", &name))) .width(240.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -580,7 +586,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(&lw_resp) - .id(egui::Id::new(("lpm_lw", &name))) + .id(egui::Id::unique(("lpm_lw", &name))) .width(170.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -611,7 +617,7 @@ fn row( }); }); // Transparency (commits when the drag or edit ends). - let tid = egui::Id::new(("lpm_tr", &name)); + let tid = egui::Id::unique(("lpm_tr", &name)); let mut tr = ui.data_mut(|d| d.get_temp::(tid)).unwrap_or(l.transparency); let r = ui.add(egui::DragValue::new(&mut tr).range(0..=90).speed(0.5)); if r.dragged() || r.has_focus() { @@ -632,7 +638,7 @@ fn row( } let vc = v.layer_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| *c); ui.horizontal(|ui| { - if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); } if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() { @@ -653,7 +659,9 @@ fn row( // Description (double-click to edit). match st.describing.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::new(("lpm_desc", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::unique(("lpm_desc", &name)))) + .accessible_name(crate::tl!("Description")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -750,7 +758,7 @@ mod tests { dialog(&mut app, ui.ctx(), &mut open); }); out.textures_delta.clear(); - let id = egui::LayerId::new(egui::Order::Middle, egui::Id::new("lpm_window")); + let id = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lpm_window")); heights.push(ctx.memory(|m| m.area_rect(id.id)).map_or(0.0, |r| r.height())); } let (first, last) = (heights[5], heights[29]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a627baf08..d3799dc36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod animation; pub mod autosave; pub mod bedit; pub mod canvas; +pub mod capture; pub mod chrome; pub mod clipboard; pub mod closing; @@ -24,6 +25,7 @@ pub mod dialogs; pub mod docking; pub mod hatchdlg; mod mtext_editor; +mod panel_state; pub mod spacemouse; // The External References palette. pub mod xrefs; @@ -220,15 +222,28 @@ pub struct CadApp { pub opening: opening::Opening, pub integrated_titlebar: bool, control_rx: Option>, - pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, - queued_shots: Vec<(u64, f64, u32)>, - shot_token: u64, + /// Fresh surface and identity snapshot (see [`capture`]). + pub(crate) capture_geometry: Option, + /// Monotonic surface epoch, bumped whenever the window/viewport geometry changes. + pub(crate) capture_epoch: u64, + /// Monotonic identity epoch, bumped whenever the drawing or focused widget changes (even + /// when a later change returns to the same value). + pub(crate) capture_identity_epoch: u64, + /// Admitted `ui.screenshot` requests, including completed-but-undrained ones. + pub(crate) shots: capture::Shots, pub synthetic: Vec, + /// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call. + synthetic_injected: bool, + /// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or + /// discarded passes). + pub ui_frames: u64, /// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]). #[cfg(not(target_arch = "wasm32"))] pub frame_cap: Option, /// Language whose interface fonts are installed (`None` until the first frame). fonts_language: Option<&'static str>, + /// Language last given to egui (and so to the AccessKit root; `None` until the first frame). + shown_language: Option<&'static str>, /// The theme last installed into egui (`None` until the first frame). shown_theme: Option, /// Whether the last applied theme was handed to egui as the System preference. @@ -261,6 +276,7 @@ pub struct CadApp { } impl CadApp { + /// Construct `CadApp` from a session and services with default UI state, options, canvas and command line; no control channel, capture geometry or pending shots are installed. pub fn new(session: Session, services: Services) -> Self { CadApp { system_languages: Vec::new(), @@ -276,13 +292,17 @@ impl CadApp { opening: opening::Opening::default(), integrated_titlebar: false, control_rx: None, - pending_shots: Vec::new(), - queued_shots: Vec::new(), - shot_token: 0, + capture_geometry: None, + capture_epoch: 0, + capture_identity_epoch: 0, + shots: capture::Shots::default(), synthetic: Vec::new(), + synthetic_injected: false, + ui_frames: 0, #[cfg(not(target_arch = "wasm32"))] frame_cap: None, fonts_language: None, + shown_language: None, shown_theme: None, shown_follows_system: false, shown_animations: None, @@ -432,6 +452,11 @@ impl CadApp { self.status = Some((s.into(), now_ms())); } + /// Cancel queued/in-flight screenshots (host exit). Late callbacks write nothing. + pub fn cancel_screenshots(&mut self) { + capture::cancel_all(self); + } + /// Open the drawing at `path`. Natively the file is read on a worker thread while an /// "Opening ..." card shows ([`opening`]); [`Self::finish_opening`] adds it when ready. On the /// web it opens at once. @@ -548,22 +573,121 @@ impl CadApp { recent.truncate(RECENT_FILES); } - /// Per-frame logic before layout. + /// Consume asynchronous work on every logic callback, including hidden `run_logic` ticks. + /// + /// Consume-based queues and screenshot settlement run on **every** call, whichever pass + /// delivers it. Frame work (LISP progress, autosave, close requests, screenshot issuance + /// pacing, hidden repaints) runs once per host frame: eframe calls `App::logic` inside every + /// pass of a multi-pass frame, and also on hidden logic-only ticks where no pass runs and the + /// pass index is zero. `current_pass_index() == 0` is therefore the frame guard: true on the + /// first pass and every hidden tick, false on later passes. pub fn logic(&mut self, ctx: &egui::Context) { - // A long LISP routine runs a slice per frame, so the window stays live (and Esc stops it). - self.session.lisp_tick(); - if self.session.lisp_busy() { - ctx.request_repaint(); + // Consume-based queues: each item is handled once, whichever callback delivers it. A + // reply arriving during a later pass is never starved until the next frame. + self.drain_control(ctx); + self.finish_opening(); + // Finishing an open switches to the new drawing. + capture::note_identity(self, ctx); + capture::drain(self, ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + // What follows is frame work, once per layout frame or hidden tick. + if ctx.current_pass_index() != 0 { + return; } - // Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first. + // Closing the window with unsaved changes asks first. `run_logic` refreshes viewport + // close/focus state, so this must also work while hidden. if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } + // A long AutoLISP routine runs one bounded slice per frame, including hidden web/native + // ticks; it must not depend on a paint or `RequestPaintWhileHidden`. + self.session.lisp_tick(); + // Time-based and idempotent by its own deadline; keeps autosave alive while hidden. + self.autosave_tick(ctx); + if !self.synthetic.is_empty() || self.synthetic_injected { + request_paint_while_hidden(ctx); + } + if self.session.lisp_busy() || self.opening.busy() { + request_paint_while_hidden(ctx); + } + capture::request_hidden_paint(self, ctx); + } + + /// Input hook: refresh the raw surface snapshot and release at most one synthetic batch. + /// + /// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in + /// the buffer is never injected again; the next batch waits for a real `ui` pass to consume + /// the previous one. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + capture::note_raw_geometry(self, ctx, raw); + if self.synthetic_injected || self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. + match self.synthetic.first() { + Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + _ => {} + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + self.synthetic_injected = true; + } + + /// Lay out the whole window. This is the only place with a real UI pass. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + self.ui_frame_begin(ui); + } else { + // A second pass must render the state the first pass produced. egui's raw input is + // empty there, but the smoothed scroll delta can persist; never pan/zoom twice. + ctx.input_mut(|input| { + input.smooth_scroll_delta = egui::Vec2::ZERO; + input.raw.events.retain(|event| !matches!(event, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_))); + }); + } + // The settled geometry and ppp of this pass, before drawing and after the frame's saved + // interface zoom was applied (egui applies a new zoom at the start of a pass). + capture::note_pass_geometry(self, &ctx); + i18n::with_language(self.language(), || self.draw(ui)); + if first_pass { + // The batch reached an actual Ui callback; a discarded pass still consumed it. + self.synthetic_injected = false; + } + // `will_discard()` is false on the settled (final) pass of a frame, whether the frame is + // single-pass or the second pass after a layout discard; a discarded pass is skipped. + if !ctx.will_discard() { + self.ui_frames = self.ui_frames.saturating_add(1); + capture::issue(self, &ctx); + } + } + + /// Frame-only work, run from the first real UI pass (never from a hidden logic tick). + fn ui_frame_begin(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let language = self.language(); if self.fonts_language != Some(language) { theme::install_fonts(ctx, language); self.fonts_language = Some(language); } + // The AccessKit root carries the language the interface is actually shown in; egui copies + // it onto the root node at the end of the pass. + if self.shown_language != Some(language) { + ctx.set_language(Some(language.to_string())); + self.shown_language = Some(language); + } // Resolved every frame, so a System choice follows the OS appearance live (winit and the // browser report changes as input); the saved choice itself never changes here. let shown = self.ui.theme.resolve(ctx.system_theme()); @@ -591,59 +715,34 @@ impl CadApp { ctx.set_zoom_factor(scale); self.shown_interface_scale = Some(scale); } - self.drain_control(ctx); - self.finish_opening(); - self.opening.show(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); - } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - self.gpu_fault_tick(ctx); - managers::plot_window_resume(self, ctx); - hatchdlg::resume(self); - self.autosave_tick(ctx); - menus::shortcuts(self, ctx); - #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - self.open_path(&p); - } - } - } - - /// Wait for the frame cap (if any), then inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + // Wait for the frame cap on a painted pass, where a wait cannot delay a hidden tick. #[cfg(not(target_arch = "wasm32"))] if let Some(cap) = &mut self.frame_cap { cap.wait(); } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. - match self.synthetic.first() { - Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), - Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), - _ => {} - } - let n = n.min(self.synthetic.len()); - raw.events.extend(self.synthetic.drain(..n)); + panel_state::migrate(ui); + self.ui_input(ctx); } - /// Lay out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::with_language(self.language(), || self.draw(ui)); + /// Input-derived work, once per layout frame. + fn ui_input(&mut self, ctx: &egui::Context) { + menus::shortcuts(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + { + let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); + for file in dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + self.open_path(&path); + } + } + } + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } } + /// Draw one UI pass: title/toolbar, optional menu/status bar/file tabs, the standalone tool-set strip when a drawing is shown but Tool Sets is not docked, docking or the start page, and the floating overlays; record `frame_ms`. Runs on every pass, including one egui later discards. fn draw(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); control::begin_frame(ui.ctx()); @@ -669,6 +768,7 @@ impl CadApp { chrome::start_page(self, ui); } }); + self.opening.show(ui.ctx()); dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); @@ -677,6 +777,7 @@ impl CadApp { self.frame_ms = now_ms() - t0; } + /// Drain each queued control request at most once (timed-out requests are skipped), dispatch it to `control::handle` or `capture::admit`, note the resulting capture identity and request a repaint. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -689,85 +790,31 @@ impl CadApp { control::Outcome::Done(v) => { let _ = reply.send(v); } - control::Outcome::Screenshot { path } => { - self.shot_token += 1; - let token = self.shot_token; - self.queued_shots.push((token, now_ms() + 120.0, 0)); - self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); - } + control::Outcome::Screenshot { path } => capture::admit(self, ctx, path, reply), } + // A command can switch drawings or move the focus; a screenshot queued before it must + // see that transition even if a later command returns to the same drawing. + capture::note_identity(self, ctx); ctx.request_repaint(); } self.control_rx = Some(rx); } - - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - // Wait for a background rebuild for the current view, so the shot shows the drawing at - // that view, but not past the reply's deadline. - let building = self.canvas.building(); - self.queued_shots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 && (!building || now >= *at + SHOT_REBUILD_WAIT_MS) { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_shots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_shots.remove(i); - let _ = reply.send(control::save_screenshot(&image, path.as_deref())); - } - } - let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; - } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); - false - }); - } } -/// How long a control-channel screenshot waits for a background rebuild before it is taken -/// anyway (the reply itself times out after 8 s). -const SHOT_REBUILD_WAIT_MS: f64 = 6000.0; - -pub fn now_ms() -> f64 { +/// Ask for a painted frame while no frame is being painted. Native eframe honours the viewport +/// command; the pinned web runner logs it as unhandled, so web only schedules a repaint (hidden +/// work still advances from `logic`). +fn request_paint_while_hidden(ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); +} + +/// Monotonic milliseconds since the first call, on native and web (`performance.now` on wasm). +/// Durations (screenshot deadlines, status expiry, frame timings) must not follow the wall clock. +pub fn now_ms() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 } /// A LISP source file, by its name. @@ -784,13 +831,141 @@ mod tests { CadApp::new(Session::empty(), Services::default()) } - /// One frame of `logic` with the OS reporting `system` as its appearance. + /// One real UI frame, with the OS reporting `system` as its appearance. fn frame(app: &mut CadApp, ctx: &egui::Context, system: Option) { let raw = egui::RawInput { system_theme: system, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); } + /// One real UI frame whose first pass asks egui for a second layout pass, as a popup or a + /// changed layout does. + fn two_pass_frame(app: &mut CadApp, ctx: &egui::Context) { + let mut discard = true; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + } + + /// Eframe runs `App::logic` inside every pass of a multi-pass frame. Frame work (here one + /// AutoLISP slice) belongs to the frame, not to a pass. + #[test] + fn a_two_pass_frame_runs_frame_logic_once() { + use cadcraft_engine::lisp::value::Value; + + let routine = "(setq cc-two 0) (while (< cc-two 400000) (setq cc-two (1+ cc-two)))"; + let start = |app: &mut CadApp| { + let started = app.session.lisp_eval(routine, false, false, Vec::new()).expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(app.session.lisp_busy()); + }; + let mut single = CadApp::new(Session::new(), Services::default()); + let mut twice = CadApp::new(Session::new(), Services::default()); + start(&mut single); + start(&mut twice); + let ctx = egui::Context::default(); + frame(&mut single, &ctx, None); + two_pass_frame(&mut twice, &ctx); + let counter = |app: &CadApp| match app.session.lisp_var("CC-TWO") { + Value::Int(n) => n, + other => panic!("CC-TWO is {other:?}, not an integer"), + }; + assert!(counter(&single) > 0, "the frame ran the first slice"); + assert_eq!(counter(&twice), counter(&single), "the second pass did not run a second slice"); + assert_eq!(twice.session.lisp_busy(), single.session.lisp_busy(), "the same amount of the routine ran"); + } + + /// A control request is consumed once per frame, not once per pass. + #[test] + fn a_two_pass_frame_executes_a_control_request_once() { + use std::time::Duration; + + use crate::control::ControlRequest; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = CadApp::new(Session::new(), Services::default()).with_control(rx); + let (request, reply) = ControlRequest::new("engine.execute", json!({ "command": "line", "params": { "points": [[0.0, 0.0], [10.0, 0.0]] } })); + tx.send(request).expect("the request is queued"); + let ctx = egui::Context::default(); + two_pass_frame(&mut app, &ctx); + let entities = app.session.doc().map(cadcraft_doc::Drawing::entity_count).unwrap_or_default(); + assert_eq!(entities, 1, "one command ran once, in the first pass"); + let reply = reply.wait(Duration::from_secs(1)); + assert_eq!(reply["ok"], true, "{reply}"); + } + + /// A hidden logic-only tick runs no frame-only work: `ui_frames` stays 0 and fonts are not installed; a real `Ui` pass advances both. + #[test] + fn hidden_logic_does_not_run_frame_only_work() { + let mut a = app(); + let ctx = egui::Context::default(); + // A logic-only call (what eframe runs for a hidden window) reports pass index zero too. + a.logic(&ctx); + assert_eq!(a.ui_frames, 0, "no real Ui callback ran"); + assert_eq!(a.fonts_language, None, "fonts are a frame-only concern"); + frame(&mut a, &ctx, None); + assert_eq!(a.ui_frames, 1); + assert!(a.fonts_language.is_some()); + } + + /// Repeated hidden logic-only ticks finish a long Lisp routine (`CC-HIDDEN` reaches 400000) while `ui_frames` stays 0. + #[test] + fn hidden_logic_advances_a_long_lisp_routine_without_a_ui_pass() { + use cadcraft_engine::lisp::value::Value; + + let mut a = CadApp::new(Session::new(), Services::default()); + let ctx = egui::Context::default(); + let started = a + .session + .lisp_eval("(setq cc-hidden 0) (while (< cc-hidden 400000) (setq cc-hidden (1+ cc-hidden)))", false, false, Vec::new()) + .expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(a.session.lisp_busy(), "the first slice reached the yield point"); + for _ in 0..2_000 { + if !a.session.lisp_busy() { + break; + } + // What eframe runs for a hidden window: logic only, no `Ui` callback. + a.logic(&ctx); + } + assert!(!a.session.lisp_busy(), "hidden logic ticks carried the routine to the end"); + assert!(matches!(a.session.lisp_var("CC-HIDDEN"), Value::Int(400_000))); + assert_eq!(a.ui_frames, 0, "no frame was painted"); + } + + /// An injected synthetic event batch is delivered by the first raw hook, a second raw hook before a real `Ui` pass adds nothing, and a new batch is accepted after that pass. + #[test] + fn one_synthetic_batch_waits_for_a_real_ui_pass() { + let mut a = app(); + let ctx = egui::Context::default(); + a.synthetic.push(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut first = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut first); + assert!(first.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::A, .. }))); + // A hidden tick (or a second raw hook before the UI) must not append another batch. + let mut second = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "the injected batch is still pending"); + let mut out = ctx.run_ui(first, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + // The batch was consumed by a real Ui callback; a new synthetic event can now be injected. + a.synthetic.push(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut third = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut third); + assert!(third.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::B, .. }))); + } + #[test] fn theme_choice_persists_and_drives_tokens() { let mut a = app(); diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 4e008ddda..712c02a61 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -74,16 +74,18 @@ fn fmt_num(v: f64) -> String { } /// A number field that commits on Enter or focus loss. -fn num_field(ui: &mut egui::Ui, id: impl egui::AsId, v: f64) -> Option { - expr_field(ui, egui::Id::new(id), &fmt_num(v)).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) +/// +/// `label` is the visible label's widget id (see [`expr_field`]). +fn num_field(ui: &mut egui::Ui, id: impl egui::AsIdSalt, v: f64, label: Option) -> Option { + expr_field(ui, ui.make_persistent_id(id), &fmt_num(v), label).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { @@ -94,6 +96,7 @@ fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { // ---------- Linetype Manager ---------- +/// Linetype Manager: current linetype and global scale, loaded linetypes, and load/current actions; returns the command to run after drawing. fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -172,8 +175,8 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { }); ui.separator(); ui.horizontal(|ui| { - ui.label("Global scale factor"); - if let Some(v) = num_field(ui, "ltmgr_scale", ltscale).filter(|v| *v > 0.0) { + let label = ui.label("Global scale factor"); + if let Some(v) = num_field(ui, "ltmgr_scale", ltscale, Some(label.id)).filter(|v| *v > 0.0) { action = Some(("ltscale", json!({ "scale": v }))); } }); @@ -240,6 +243,7 @@ fn draw_point(p: &egui::Painter, c: egui::Pos2, r: f32, mode: i64, stroke: Strok } } +/// Point Style dialog for the PDMODE/PDSIZE presets; returns the `ddptype` action. fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -275,8 +279,8 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Point Size"); - if let Some(v) = num_field(ui, "ptype_size", size).filter(|v| *v > 0.0) { + let label = ui.label("Point Size"); + if let Some(v) = num_field(ui, "ptype_size", size, Some(label.id)).filter(|v| *v > 0.0) { action = set(mode, v, rel); } ui.label(if rel { "%" } else { "units" }); @@ -415,7 +419,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati action = Some((cmd, json!({ "name": sel }))); } left.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)).on_hover_text("New style name"); let name = new_name.trim().to_string(); let ok = !name.is_empty() && !styles.iter().any(|s| s.name.eq_ignore_ascii_case(&name)); if ui.add_enabled(ok, egui::Button::new("New")).clicked() { @@ -430,8 +434,8 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati for f in &style.fields { let change = match f { Field::Num(key, label, v) => { - ui.label(*label); - num_field(ui, (cmd, &style.name, *key), *v).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) + let field_label = ui.label(*label); + num_field(ui, (cmd, &style.name, *key), *v, Some(field_label.id)).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) } Field::Bool(key, label, v) => { ui.label(""); @@ -466,6 +470,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati // ---------- Page Setup Manager ---------- +/// Page Setup Manager over layouts sorted by tab order; returns the selected setup command. fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -502,7 +507,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action // Layout plotting always prints the whole sheet, so only that area is offered. let rows = PageRows { areas: &["layout"], scale: true, center: true }; egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| { - if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("pagesetup", name)), page, &rows, &choices, None) { action = Some(("pagesetup", json!({ "layout": name, k: v }))); } }); @@ -666,9 +671,9 @@ fn page_rows( }); ui.end_row(); for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] { - ui.label(label); + let field_label = ui.label(label); ui.add_enabled_ui(supported && !*center, |ui| { - if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) { + if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i], Some(field_label.id)) { offset_mm[i] = v; } }) @@ -683,9 +688,9 @@ fn page_rows( } } if rows.scale { - ui.label("Scale"); + let label = ui.label("Scale"); ui.add_enabled_ui(!page.scale_to_fit, |ui| { - if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale)).and_then(|t| parse_ratio(&t)) { + if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale), Some(label.id)).and_then(|t| parse_ratio(&t)) { change = Some(("scale", json!(v))); } }) @@ -955,7 +960,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { PageRows { areas: &["layout"], scale: false, center: false } }; let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported)); - if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { apply_page_change(&mut st.page, k, &v); } if model && st.page.plot_area == "window" { @@ -963,10 +968,10 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { ui.label(label); ui.horizontal(|ui| { for (j, axis) in ["X", "Y"].iter().enumerate() { - ui.label(*axis); + let axis_label = ui.label(*axis); let k = i + j; if let Some(slot) = st.window.get_mut(k) - && let Some(v) = num_field(ui, ("plot_window", k), *slot) + && let Some(v) = num_field(ui, ("plot_window", k), *slot, Some(axis_label.id)) { *slot = v; } @@ -1075,6 +1080,7 @@ fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) { // ---------- Constraint Settings ---------- +/// Constraint Settings dialog editing the drawing's parametric settings; returns the `constraintsettings` action. fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -1096,8 +1102,8 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - action = set("barsVisible", json!(bars)); } ui.horizontal(|ui| { - ui.label("Constraint bar transparency (%)"); - if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency)) { + let label = ui.label("Constraint bar transparency (%)"); + if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency), Some(label.id)) { action = set("barTransparency", json!(v)); } }); @@ -1124,13 +1130,13 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - } }); egui::Grid::new("csettings_tol").num_columns(2).show(ui, |ui| { - ui.label("Distance tolerance"); - if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance) { + let label = ui.label("Distance tolerance"); + if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance, Some(label.id)) { action = set("distanceTolerance", json!(v)); } ui.end_row(); - ui.label("Angle tolerance (degrees)"); - if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance) { + let label = ui.label("Angle tolerance (degrees)"); + if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance, Some(label.id)) { action = set("angleTolerance", json!(v)); } ui.end_row(); diff --git a/crates/ui-egui/src/managers/battman.rs b/crates/ui-egui/src/managers/battman.rs index 2c92bb219..7ec42e414 100644 --- a/crates/ui-egui/src/managers/battman.rs +++ b/crates/ui-egui/src/managers/battman.rs @@ -21,17 +21,19 @@ struct Row { } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// A text field committing on Enter / focus loss when changed (an empty value too). -fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the visible row label's widget id, so the field has an accessible name. +fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: egui::Id) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)).labelled_by(label); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(id, buf.clone())); } else { @@ -153,19 +155,19 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< ui.label(RichText::new(format!("Edit attribute {}", r.tag)).strong()); let edit = |key: &str, v: Value| json!({ "name": block, "action": "edit", "tag": r.tag, key: v }); egui::Grid::new("battman_edit").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Tag"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_tag", &block, &r.tag)), &r.tag) { + let label = ui.label("Tag"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_tag", &block, &r.tag)), &r.tag, label.id) { action = act(edit("newTag", json!(v)), apply); sel = Some(v.trim().to_ascii_uppercase()); } ui.end_row(); - ui.label("Prompt"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_prompt", &block, &r.tag)), &r.prompt) { + let label = ui.label("Prompt"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_prompt", &block, &r.tag)), &r.prompt, label.id) { action = act(edit("prompt", json!(v)), apply); } ui.end_row(); - ui.label("Default"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_default", &block, &r.tag)), &r.default) { + let label = ui.label("Default"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_default", &block, &r.tag)), &r.default, label.id) { action = act(edit("default", json!(v)), apply); } ui.end_row(); @@ -180,8 +182,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< } }); ui.end_row(); - ui.label("Height"); - let height = text_field(ui, egui::Id::new(("battman_height", &block, &r.tag)), &fmt_num(r.height)); + let label = ui.label("Height"); + let height = text_field(ui, egui::Id::unique(("battman_height", &block, &r.tag)), &fmt_num(r.height), label.id); if let Some(h) = height.and_then(|v| v.trim().parse::().ok()).filter(|h| h.is_finite() && *h > 0.0) { action = act(edit("height", json!(h)), apply); } diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 3bd54a3cd..8258a2986 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -23,6 +23,42 @@ const SWATCHES: [u16; 7] = [1, 2, 3, 4, 5, 6, 7]; /// Ruler snap, in text heights. const SNAP: f64 = 0.25; +/// Keep the actual diameter character in editable text even without optional fonts. +/// This original circle-and-slash rasterizer is a last fallback, after installed fonts. +pub(crate) fn install_diameter_glyph(ctx: &egui::Context) { + ctx.add_glyph_rasterizer(egui::GlyphRasterizer::new("cadcraft::diameter", |request| { + if request.cluster != "⌀" || !request.font_size_px.is_finite() || !(1.0..=1024.0).contains(&request.font_size_px) { + return None; + } + let size = request.font_size_px; + let side = (size * 0.8).ceil() as usize + 2; + let center = vec2(1.0 + size * 0.4, 1.0 + size * 0.4); + let radius = size * 0.3; + let half_stroke = (size * 0.05).max(1.0) * 0.5; + let start = center + vec2(-radius, radius); + let segment = vec2(2.0 * radius, -2.0 * radius); + let mut pixels = Vec::with_capacity(side * side); + for y in 0..side { + for x in 0..side { + let point = vec2(x as f32 + 0.5, y as f32 + 0.5); + let ring_distance = ((point - center).length() - radius).abs(); + let along = ((point - start).dot(segment) / segment.length_sq()).clamp(0.0, 1.0); + let slash_distance = (point - (start + segment * along)).length(); + let alpha = ((half_stroke + 0.5 - ring_distance.min(slash_distance)).clamp(0.0, 1.0) * 255.0).round() as u8; + pixels.push(Color32::from_white_alpha(alpha)); + } + } + Some(egui::RasterizedGlyph { + bitmap: egui::GlyphBitmap { + image: egui::ColorImage::new([side, side], pixels), + offset_px: vec2(0.0, -size * 0.8 - 1.0), + is_color: false, + }, + advance_px: size * 0.85 + 2.0, + }) + })); +} + #[derive(Clone)] struct State { fmt: Formatted, @@ -39,11 +75,11 @@ struct State { } fn state_id() -> egui::Id { - egui::Id::new("mtext_editor_state") + egui::Id::unique("mtext_editor_state") } fn edit_id() -> egui::Id { - egui::Id::new("mtext_editor_text") + egui::Id::unique("mtext_editor_text") } /// The ACI index or true colour as a screen colour. @@ -89,7 +125,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut done = Done::No; let top_start = pos.y - st.top; - egui::Area::new(egui::Id::new("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).inner_margin(egui::Margin::same(0)).show(ui, |ui| { ui.set_width(width); let y0 = ui.cursor().top(); @@ -186,6 +222,7 @@ fn set_flag(c: &mut CharFmt, f: Flag, on: bool) { } } +/// MTEXT formatting toolbar for the current selection: font, height, bold/italic/underline/overline, colour, alignment, lists, tab, degree/plus-minus and Diameter. Font, bold/italic/underline/overline, ByLayer/palette colour, alignment, list, tab and special-character changes refocus the editor; height and custom RGB colour changes do not. fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { let cur = current(st); let range = st.sel.0.min(st.sel.1)..st.sel.0.max(st.sel.1); @@ -194,21 +231,22 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { ui.add_space(4.0); // Font let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone()); - egui::ComboBox::from_id_salt("mtext_font").width(120.0).selected_text(shown.clone()).show_ui(ui, |ui| { - for fam in FAMILIES.iter().map(ToString::to_string).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { - if ui.selectable_label(shown == fam, &fam).clicked() { - change(st, |c| c.font = Some(fam.clone())); - refocus = true; + egui::ComboBox::from_id_salt("mtext_font") + .width(120.0) + .selected_text(shown.clone()) + .show_ui(ui, |ui| { + for fam in FAMILIES.iter().map(ToString::to_string).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { + if ui.selectable_label(shown == fam, &fam).clicked() { + change(st, |c| c.font = Some(fam.clone())); + refocus = true; + } } - } - }); + }) + .response + .accessible_name(crate::tl!("Font")); // Height let mut h = cur.height.unwrap_or(base_h); - if ui - .add(egui::DragValue::new(&mut h).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) - .on_hover_text(crate::tl!("Text height")) - .changed() - { + 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)); } @@ -226,7 +264,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } // Colour let swatch = cur.color.map_or(ui.visuals().text_color(), color32); - ui.menu_button(RichText::new("A▾").color(swatch).strong(), |ui| { + ui.menu_button(RichText::new("A").color(swatch).strong(), |ui| { if ui.button(crate::tl!("ByLayer")).clicked() { change(st, |c| c.color = None); refocus = true; @@ -253,49 +291,141 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } }) .response + .accessible_name(crate::tl!("Text color")) .on_hover_text(crate::tl!("Text color")); ui.separator(); // Paragraph alignment let para = st.fmt.para(st.fmt.paragraph_of(range.start)); - for (al, glyph, tip) in [ - (ParaAlign::Left, "⇤", crate::tl!("Left")), - (ParaAlign::Center, "↔", crate::tl!("Center")), - (ParaAlign::Right, "⇥", crate::tl!("Right")), - (ParaAlign::Justify, "≡", crate::tl!("Justify")), + for (al, tip) in [ + (ParaAlign::Left, crate::tl!("Left")), + (ParaAlign::Center, crate::tl!("Center")), + (ParaAlign::Right, crate::tl!("Right")), + (ParaAlign::Justify, crate::tl!("Justify")), ] { let on = para.align == al; - if ui.selectable_label(on, glyph).on_hover_text(tip).clicked() { + if formatting_button(ui, FormattingIcon::Align(al), tip, on) { st.fmt.edit_paras(range.clone(), |p| p.align = if on { ParaAlign::Default } else { al }); refocus = true; } } ui.separator(); // Lists and special characters - if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { + if formatting_button(ui, FormattingIcon::Bullets, crate::tl!("Bulleted list"), false) { st.fmt.make_list(range.clone(), ListStyle::Bullet, 3.0 * base_h); refocus = true; } - if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { + if formatting_button(ui, FormattingIcon::Numbers, crate::tl!("Numbered list"), false) { st.fmt.make_list(range.clone(), ListStyle::Number, 3.0 * base_h); refocus = true; } - for (glyph, tip, code) in [ - ("⇥|", crate::tl!("Insert tab"), "\t"), - ("°", crate::tl!("Degree"), "°"), - ("±", crate::tl!("Plus/minus"), "±"), - ("⌀", crate::tl!("Diameter"), "⌀"), - ] { + if formatting_button(ui, FormattingIcon::Tab, crate::tl!("Insert tab"), false) { + insert(st, "\t"); + refocus = true; + } + for (glyph, tip, code) in [("°", crate::tl!("Degree"), "°"), ("±", crate::tl!("Plus/minus"), "±")] { if ui.button(glyph).on_hover_text(tip).clicked() { insert(st, code); refocus = true; } } + if diameter_button(ui) { + insert(st, "⌀"); + refocus = true; + } }); if refocus { ui.ctx().memory_mut(|m| m.request_focus(edit_id())); } } +/// Formatting marks use geometry so the toolbar works with the bundled font stack on web too. +enum FormattingIcon { + Align(ParaAlign), + Bullets, + Numbers, + Tab, +} + +/// Glyph-only MTEXT toolbar button: paints `icon`, exposes `label` as its accessible name and tooltip, and returns whether it was clicked. +fn formatting_button(ui: &mut egui::Ui, icon: FormattingIcon, label: &str, selected: bool) -> bool { + let response = ui + .add(egui::Button::new(" ").selected(selected).min_size(vec2(22.0, ui.spacing().interact_size.y))) + .accessible_name(label) + .on_hover_text(label); + let painter = ui.painter(); + let c = response.rect.center(); + let color = if response.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.2, color); + match icon { + FormattingIcon::Align(align) => { + for row in 0..3 { + let width = if row == 1 && align != ParaAlign::Justify { 8.0 } else { 14.0 }; + let x = match align { + ParaAlign::Center => -width / 2.0, + ParaAlign::Right => 7.0 - width, + _ => -7.0, + }; + let y = (row as f32 - 1.0) * 4.0; + painter.line_segment([c + vec2(x, y), c + vec2(x + width, y)], stroke); + } + } + FormattingIcon::Bullets | FormattingIcon::Numbers => { + for row in 0..3 { + let y = (row as f32 - 1.0) * 5.0; + if matches!(icon, FormattingIcon::Bullets) { + painter.circle_filled(c + vec2(-6.0, y), 1.2, color); + } else { + painter.text(c + vec2(-6.0, y), Align2::CENTER_CENTER, (row + 1).to_string(), FontId::proportional(7.0), color); + } + painter.line_segment([c + vec2(-2.0, y), c + vec2(7.0, y)], stroke); + } + } + FormattingIcon::Tab => { + for [a, b] in [ + [vec2(-7.0, 0.0), vec2(4.0, 0.0)], + [vec2(0.0, -4.0), vec2(4.0, 0.0)], + [vec2(0.0, 4.0), vec2(4.0, 0.0)], + [vec2(7.0, -5.0), vec2(7.0, 5.0)], + ] { + painter.line_segment([c + a, c + b], stroke); + } + } + } + response.clicked() +} + +/// The MTEXT toolbar's height field. It has no visible caption, so the AccessKit label carries +/// the meaning of its hover tooltip; a tooltip alone leaves an unnamed spin button. +fn height_field(ui: &mut egui::Ui, height: &mut f64, base_h: f64) -> bool { + ui.add(egui::DragValue::new(height).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) + .on_hover_text(crate::tl!("Text height")) + .accessible_name(crate::tl!("Text height")) + .changed() +} + +/// The MTEXT Diameter button. U+2300 is absent from every bundled face (including the +/// monochrome and emoji fallbacks) and the web build has no system fonts, so the sign is painted +/// rather than shaped from text. The semantic button name keeps the glyph-only control +/// discoverable to accessibility tools. +fn diameter_button(ui: &mut egui::Ui) -> bool { + let min_size = vec2(22.0, ui.spacing().interact_size.y); + let resp = ui.add(egui::Button::new(" ").min_size(min_size)).on_hover_text(crate::tl!("Diameter")); + let color = if resp.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.4, color); + let center = resp.rect.center(); + let radius = resp.rect.height() * 0.3; + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let painter = ui.painter(); + painter.circle_stroke(center, radius, stroke); + painter.line_segment([center + vec2(-diagonal, diagonal), center + vec2(diagonal, -diagonal)], stroke); + // Name the node directly as well as through `WidgetInfo`: on a clicked frame `widget_info` + // emits an output event and does not update the AccessKit node, which would leave the + // glyph-only button unnamed for that frame. + let resp = resp.accessible_name(crate::tl!("Diameter")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), crate::tl!("Diameter"))); + resp.clicked() +} + /// Insert `s` at the caret (over the selection). fn insert(st: &mut State, s: &str) { let (a, b) = (st.sel.0.min(st.sel.1), st.sel.0.max(st.sel.1)); @@ -347,7 +477,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { let y = if up { rect.bottom() - 1.0 } else { rect.top() + 1.0 }; let dir = if up { -1.0 } else { 1.0 }; let hit = Rect::from_center_size(pos2(x, y + dir * 4.0), vec2(10.0, 10.0)); - let r = ui.interact(hit, ui.id().with(("mtext_indent", k)), Sense::drag()); + let r = ui.interact(hit, ui.scope_id().with(("mtext_indent", k)), Sense::drag()); let col = if r.hovered() || r.dragged() { t.accent } else { t.icon }; painter.add(Shape::convex_polygon(vec![pos2(x - 4.0, y), pos2(x + 4.0, y), pos2(x, y + dir * 8.0)], col, Stroke::NONE)); if r.dragged() @@ -374,6 +504,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { bg.on_hover_text(crate::tl!("Click the ruler to add a tab stop, right-click to remove one")); } +/// 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() @@ -436,6 +567,7 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, .char_limit(cadcraft_engine::mtext_edit::MAX_CHARS) .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() { out.response.request_focus(); } @@ -462,9 +594,94 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, mod tests { use super::*; use cadcraft_engine::Session; + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; use crate::Services; + /// 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 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); + text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get()); + }, + state, + ); + harness.run(); + harness.get_by_role_and_label(Role::Button, "Diameter").click(); + harness.run(); + assert_eq!(harness.state().fmt.text(), "⌀"); + assert_eq!(harness.state().fmt.to_contents(), "⌀"); + assert_eq!(harness.state().sel, (1, 1)); + assert!(harness.unnamed_widgets().is_empty()); + } + + /// 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 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"] { + assert!(harness.query_by_label(name).is_some(), "missing semantic control {name}"); + } + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// Strict bundled-font and accessibility regression for the glyph-only Diameter button: the + /// test context uses egui's bundled faces only (no `theme::install_fonts` system prepend), + /// and the harness panics on a missing glyph by default. The sign must be painted, not + /// shaped from U+2300. + #[test] + fn diameter_button_is_painted_and_named_under_bundled_fonts() { + let ctx = egui::Context::default(); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0, 60.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + diameter_button(ui); + }); + out.textures_delta.clear(); + let mut text = String::new(); + let (mut circle, mut slash) = (false, false); + for shape in &out.shapes { + match &shape.shape { + Shape::Circle(_) => circle = true, + Shape::LineSegment { .. } => slash = true, + Shape::Text(t) => text.push_str(&t.galley.job.text), + _ => {} + } + } + assert!(circle, "the diameter sign's circle is painted"); + assert!(slash, "the diameter sign's slash is painted"); + assert!(!text.contains('\u{2300}'), "U+2300 must not be shaped from a font: {text:?}"); + + let mut harness = Harness::builder().with_size(vec2(240.0, 80.0)).build_ui(|ui| { + diameter_button(ui); + }); + harness.run(); + let button = harness.get_by_label("Diameter"); + assert_eq!(button.accesskit_node().role(), Role::Button); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// The height DragValue has no visible caption, so its AccessKit label must be set explicitly. + /// Bundled fonts only: no system face can mask a missing glyph in the harness. + #[test] + fn height_field_is_named_for_accessibility_under_bundled_fonts() { + let mut harness = Harness::builder().with_size(vec2(360.0, 80.0)).build_ui(|ui| { + let mut height = 2.5; + height_field(ui, &mut height, 2.5); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::SpinButton, "Text height"); + assert_eq!(field.accesskit_node().label().as_deref(), Some("Text height")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + #[test] fn editor_opens_only_for_mtext_text_entry_and_finishes_with_formatted_contents() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index a839850d9..b0b2f8781 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -77,7 +77,7 @@ impl Opening { pub fn show(&self, ctx: &egui::Context) { let Some(label) = self.label() else { return }; let t = Tokens::get(); - egui::Area::new(egui::Id::new("cc_opening")) + egui::Area::new(egui::Id::unique("cc_opening")) .order(egui::Order::Foreground) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .interactable(false) diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 7c0a22af6..cfc1833f0 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -33,7 +33,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool let mut close = false; let center = ctx.content_rect().center(); egui::Window::new(crate::tl!("Options")) - .id(egui::Id::new("cc_options")) + .id(egui::Id::unique("cc_options")) .open(open) .resizable(false) .collapsible(false) @@ -110,11 +110,12 @@ fn animations_label(a: AnimationPref) -> &'static str { } /// A colour button, its hex value and a Default button; a change goes into `changes` under `key`. -fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map) { +/// `label` is the visible row label, so a screen reader names the colour well by it. +fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let shown = current.unwrap_or(default); let mut rgb = [shown.r(), shown.g(), shown.b()]; - if ui.color_edit_button_srgb(&mut rgb).changed() { + if ui.color_edit_button_srgb(&mut rgb).labelled_by(label).changed() { changes.insert(key.into(), json!(super::hex(Color32::from_rgb(rgb[0], rgb[1], rgb[2])))); } ui.monospace(super::hex(shown)); @@ -135,36 +136,39 @@ fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) { p.rect_stroke(egui::Rect::from_center_size(rect.center(), vec2(side, side)), 0.0, Stroke::new(2.0, color), egui::StrokeKind::Middle); } +/// Display tab: interface theme, animation preference and scale, drawing-area colours, crosshair size, history length and the system-cursor option; edits accumulate in `changes`. fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_display", |ui| { - ui.label(crate::tl!("Interface theme")); + let theme_caption = ui.label(crate::tl!("Interface theme")); let mut theme = app.ui.theme; - egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { + let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { for t in ThemePref::ALL { ui.selectable_value(&mut theme, t, theme_label(t)); } }); + theme_combo.response.labelled_by(theme_caption.id); if theme != app.ui.theme { changes.insert("theme".into(), json!(theme.as_str())); } ui.end_row(); - ui.label(crate::tl!("Animation effects")); + let animations_caption = ui.label(crate::tl!("Animation effects")); let mut animations = app.ui.animations; - egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { + let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { for a in AnimationPref::ALL { ui.selectable_value(&mut animations, a, animations_label(a)); } }); + animations_combo.response.labelled_by(animations_caption.id); if animations != app.ui.animations { changes.insert("animations".into(), json!(animations.as_str())); } ui.end_row(); - ui.label(crate::tl!("Interface scale")); + let scale_caption = ui.label(crate::tl!("Interface scale")); let scale = app.options.interface_scale; let percent = |s: f32| format!("{} %", (s * 100.0).round()); - egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { + let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { for n in [75u16, 100, 125, 150, 175, 200, 250, 300] { let s = f32::from(n) / 100.0; if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 { @@ -172,26 +176,32 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { } } }); + scale_combo.response.labelled_by(scale_caption.id); ui.end_row(); - ui.label(crate::tl!("Model background")); - color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes); + let background_caption = ui.label(crate::tl!("Model background")); + color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes, background_caption.id); ui.end_row(); - ui.label(crate::tl!("Layout paper")); - color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes); + let paper_caption = ui.label(crate::tl!("Layout paper")); + color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes, paper_caption.id); ui.end_row(); - ui.label(crate::tl!("Crosshair size")); - let mut size = app.session.settings.cursorsize; - if ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().suffix(" %")).changed() { - changes.insert("CURSORSIZE".into(), json!(size)); - } + let crosshair_caption = ui.label(crate::tl!("Crosshair size")); + ui.horizontal(|ui| { + let mut size = app.session.settings.cursorsize; + // The slider's own value box is a separate spin button: name both halves with the row label. + let slider = ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().show_value(false)).labelled_by(crosshair_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(1.0..=100.0).max_decimals(0).suffix(" %")).labelled_by(crosshair_caption.id); + if slider.changed() || field.changed() { + changes.insert("CURSORSIZE".into(), json!(size)); + } + }); ui.end_row(); - ui.label(crate::tl!("Command line history")); + let history_caption = ui.label(crate::tl!("Command line history")); let mut lines = app.ui.history_lines; - if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).changed() { + if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).labelled_by(history_caption.id).changed() { changes.insert("historyLines".into(), json!(lines)); } ui.end_row(); @@ -204,6 +214,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Screen magnifiers, screen readers and recorders follow the system cursor.")); } +/// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`. fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_files", |ui| { ui.label(crate::tl!("Save new drawings as")); @@ -220,14 +231,15 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { }); ui.end_row(); - ui.label(crate::tl!("Default save version")); + let version_caption = ui.label(crate::tl!("Default save version")); let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); let mut version = current; - egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { for v in crate::SaveVersion::ALL { ui.selectable_value(&mut version, v, v.year()); } }); + version_combo.response.labelled_by(version_caption.id); if version != current { changes.insert("saveVersion".into(), json!(version.year())); } @@ -245,20 +257,24 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { }); ui.end_row(); - ui.label(crate::tl!("Automatic save every")); + let minutes_caption = ui.label(crate::tl!("Automatic save every")); let mut minutes = cadcraft_engine::autosave::savetime(); let max = cadcraft_engine::autosave::MAX_SAVETIME; - if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).changed() { + if ui + .add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))) + .labelled_by(minutes_caption.id) + .changed() + { changes.insert("SAVETIME".into(), json!(minutes)); } ui.end_row(); - ui.label(crate::tl!("Automatic save file location")); + let savefile_label = ui.label(crate::tl!("Automatic save file location")); // Edited as text; the folder changes when the field loses focus. - let id = egui::Id::new("cc_options_savefilepath"); + let id = egui::Id::unique("cc_options_savefilepath"); let current = cadcraft_engine::autosave::savefilepath(); let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); if r.changed() { ui.data_mut(|d| d.insert_temp(id, path.clone())); } @@ -275,23 +291,28 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } +/// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`. fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { let t = Tokens::get(); grid(ui, "options_drafting", |ui| { - ui.label(crate::tl!("Object snap marker size")); + let marker_caption = ui.label(crate::tl!("Object snap marker size")); ui.horizontal(|ui| { let mut size = app.options.snap_marker_size; - if ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer()).changed() { + let slider = ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer().show_value(false)).labelled_by(marker_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(super::SNAP_MARKER_SIZES).max_decimals(0)).labelled_by(marker_caption.id); + if slider.changed() || field.changed() { changes.insert("snapMarkerSize".into(), json!(size)); } preview_square(ui, size, t.snap); }); ui.end_row(); - ui.label(crate::tl!("Object snap aperture")); + let aperture_caption = ui.label(crate::tl!("Object snap aperture")); ui.horizontal(|ui| { let mut aperture = app.session.settings.aperture; - if ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().show_value(false)).labelled_by(aperture_caption.id); + let field = ui.add(egui::DragValue::new(&mut aperture).range(1.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(aperture_caption.id); + if slider.changed() || field.changed() { changes.insert("APERTURE".into(), json!(aperture)); } preview_square(ui, aperture as f32, t.crosshair); @@ -308,17 +329,20 @@ fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, s }); } +/// Selection tab: pickbox and selection-mode controls, selection preview/area effects and the Visual Effect Settings window; edits accumulate in `changes`. fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { let t = Tokens::get(); let st = &app.session.settings; - let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::new(VISUAL_EFFECTS)).unwrap_or(false)); + let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(VISUAL_EFFECTS)).unwrap_or(false)); ui.columns(2, |cols| { let [left, right] = cols else { return }; // Left: how objects are picked. - heading(left, crate::tl!("Pickbox size")); + let pickbox_caption = heading(left, crate::tl!("Pickbox size")); left.horizontal(|ui| { let mut pickbox = st.pickbox; - if ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().show_value(false)).labelled_by(pickbox_caption.id); + let field = ui.add(egui::DragValue::new(&mut pickbox).range(0.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(pickbox_caption.id); + if slider.changed() || field.changed() { changes.insert("PICKBOX".into(), json!(pickbox)); } preview_square(ui, (pickbox * 3.0) as f32, t.crosshair); @@ -350,14 +374,23 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) changes.insert("QPMODE".into(), json!(i32::from(qp))); } left.add_space(4.0); - left.label(crate::tl!("Selection effect color")); - color_row(left, app.options.selection_color, crate::theme::Tokens::of(crate::theme::active()).selection, "selectionColor", changes); + let selection_caption = left.label(crate::tl!("Selection effect color")); + color_row( + left, + app.options.selection_color, + crate::theme::Tokens::of(crate::theme::active()).selection, + "selectionColor", + changes, + selection_caption.id, + ); // Right: grips and the preview under the cursor. - heading(right, crate::tl!("Grip size")); + let grip_caption = heading(right, crate::tl!("Grip size")); right.horizontal(|ui| { let mut grip = st.gripsize; - if ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().show_value(false)).labelled_by(grip_caption.id); + let field = ui.add(egui::DragValue::new(&mut grip).range(1.0..=20.0).max_decimals(0).suffix(" px")).labelled_by(grip_caption.id); + if slider.changed() || field.changed() { changes.insert("GRIPSIZE".into(), json!(grip)); } preview_square(ui, (grip * 2.0) as f32, t.grip); @@ -370,10 +403,11 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) } right.horizontal(|ui| { let mut limit = st.gripobjlimit; - if ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)).changed() { + let field = ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)); + let caption = ui.label(crate::tl!("Object selection limit for display of grips")); + if field.labelled_by(caption.id).changed() { changes.insert("GRIPOBJLIMIT".into(), json!(limit)); } - ui.label(crate::tl!("Object selection limit for display of grips")); }); right.add_space(8.0); heading(right, crate::tl!("Preview")); @@ -391,14 +425,15 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) if effects { visual_effects(ui.ctx(), st, &mut effects, changes); } - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(VISUAL_EFFECTS), effects)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(VISUAL_EFFECTS), effects)); } /// Whether the Visual Effect Settings window is open (egui temp data). const VISUAL_EFFECTS: &str = "cc_options_visual_effects"; -fn heading(ui: &mut egui::Ui, text: &str) { - ui.label(RichText::new(text).strong()); +/// Strong (bold) label for section headings in the Options Selection tab and the Visual Effect Settings window. +fn heading(ui: &mut egui::Ui, text: &str) -> egui::Response { + ui.label(RichText::new(text).strong()) } /// Visual Effect Settings: the selection area (window and crossing fills) and the objects the @@ -406,7 +441,7 @@ fn heading(ui: &mut egui::Ui, text: &str) { fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mut bool, changes: &mut Map) { let mut close = false; egui::Window::new(crate::tl!("Visual Effect Settings")) - .id(egui::Id::new("cc_visual_effects")) + .id(egui::Id::unique("cc_visual_effects")) .open(open) .resizable(false) .collapsible(false) @@ -421,15 +456,19 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu changes.insert("SELECTIONAREA".into(), json!(i32::from(area))); } left.add_enabled_ui(st.selectionarea, |ui| { - ui.label(crate::tl!("Window selection color:")); - aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes); - ui.label(crate::tl!("Crossing selection color:")); - aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes); - ui.label(crate::tl!("Selection area opacity:")); - let mut opacity = st.selectionareaopacity; - if ui.add(egui::Slider::new(&mut opacity, 0..=100)).changed() { - changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); - } + let window_caption = ui.label(crate::tl!("Window selection color:")); + aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes, window_caption.id); + let crossing_caption = ui.label(crate::tl!("Crossing selection color:")); + aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes, crossing_caption.id); + let opacity_caption = ui.label(crate::tl!("Selection area opacity:")); + ui.horizontal(|ui| { + let mut opacity = st.selectionareaopacity; + let slider = ui.add(egui::Slider::new(&mut opacity, 0..=100).show_value(false)).labelled_by(opacity_caption.id); + let field = ui.add(egui::DragValue::new(&mut opacity).range(0..=100)).labelled_by(opacity_caption.id); + if slider.changed() || field.changed() { + changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); + } + }); }); heading(right, crate::tl!("Selection Preview Filter")); @@ -463,35 +502,41 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu } /// A colour by its index (ACI 1-255): a swatch and the number; a change goes into `changes`. -fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map) { +fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let c = cadcraft_color::aci_rgb(current); let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); ui.painter().rect_filled(r, 2.0, Color32::from_rgb(c.0, c.1, c.2)); let mut v = current; - if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).changed() { + if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).labelled_by(label).changed() { changes.insert(key.into(), json!(v)); } }); } +/// Preferences tab: interface language, largest-array limit and time-sensitive right-click; edits accumulate in `changes`. fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_preferences", |ui| { - ui.label(crate::tl!("Interface language")); + let language_caption = ui.label(crate::tl!("Interface language")); let mut lang = app.ui.interface_language; - egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { + let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { for p in crate::i18n::Preference::for_picker() { ui.selectable_value(&mut lang, p, p.name()); } }); + language_combo.response.labelled_by(language_caption.id); if lang != app.ui.interface_language { changes.insert("interfaceLanguage".into(), json!(lang.code())); } ui.end_row(); - ui.label(crate::tl!("Largest array")); + let array_caption = ui.label(crate::tl!("Largest array")); let mut max = app.session.settings.maxarray; - if ui.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))).changed() { + if ui + .add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))) + .labelled_by(array_caption.id) + .changed() + { changes.insert("MAXARRAY".into(), json!(max)); } ui.end_row(); diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 29bedf630..78e2d7e93 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -139,6 +139,7 @@ fn command_known(id: &str) -> bool { cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id) } +/// Tool Sets strip with Drafting/Modeling tabs, their command buttons and the collapse control. pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Frame::NONE.fill(t.panel).show(ui, |ui| { @@ -151,7 +152,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let w = 92.0; let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, tab_h)); let active = app.ui.toolset_tab == name; - let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); + let resp = ui.interact(tr, ui.scope_id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); p.text( tr.center(), @@ -168,7 +169,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { } p.rect_filled(Rect::from_min_max(pos2(x, r.top()), pos2(r.right(), r.top() + tab_h)), 0.0, t.chrome); let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false); if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() { @@ -202,7 +203,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { t.text, ); let gear = Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(13.0, 13.0)); - let gresp = ui.interact(gear, ui.id().with(("ts_gear", name)), Sense::click()); + let gresp = ui.interact(gear, ui.scope_id().with(("ts_gear", name)), Sense::click()); icons::paint(pp, Rect::from_center_size(gear.center(), vec2(11.0, 11.0)), Icon::Gear, false); icons::describe_control(ui, &hresp, name, Some(!collapsed), false); icons::describe_control(ui, &gresp, &format!("{name} options"), None, false); @@ -329,7 +330,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::left("cc_toolsets_strip").exact_size(22.0).resizable(false).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); - let resp = ui.interact(r, ui.id().with("ts_expand"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("ts_expand"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 14.0), vec2(14.0, 14.0)), Icon::ChevronRight, false); icons::describe_control(ui, &resp, "Expand Tool Sets", None, false); if resp.on_hover_text(crate::tl!("Tool Sets")).clicked() { @@ -338,6 +339,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { }); } +/// Right palette panel (Layers/Blocks/Properties icon strip and collapse control) containing the Layers and Properties sections. pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { if !app.ui.show_palettes { return; @@ -354,7 +356,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { icons::paint(&p, br, *icon, false); } let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("palettes_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("palettes_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronRight, false); icons::describe_control(ui, &cresp, "Collapse Palettes", None, false); if cresp.on_hover_text(crate::tl!("Collapse Palettes")).clicked() { @@ -514,15 +516,17 @@ pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// Properties row: a right-aligned translated label in a 106-point layout box that exposes a Label node to accessibility and names the control added by `add`. fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|ui| { let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0); job.halign = egui::Align::RIGHT; let galley = ui.painter().layout_job(job); - let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + let (lr, label_response) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + label_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), crate::i18n::t(label))); ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim); - add(ui); + ui.label_inputs_by(label_response.id, add); }); } @@ -612,7 +616,7 @@ type Custom = Option<(String, Vec Custom { let st = app.session.state().ok()?; - let id = egui::Id::new(("dynamic-block-custom", st.uid, st.revision, h)); + let id = egui::Id::unique(("dynamic-block-custom", st.uid, st.revision, h)); if let Some(c) = ui.data(|d| d.get_temp::(id)) { return c; } @@ -639,7 +643,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().rect_filled(if app.ui.properties_all { half } else { my }, 3.0, t.control); ui.painter().text(half.center(), egui::Align2::CENTER_CENTER, crate::tl!("All"), crate::theme::small(), t.text); ui.painter().text(my.center(), egui::Align2::CENTER_CENTER, crate::tl!("My"), crate::theme::small(), t.text_faint); - ui.interact(my, ui.id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); + ui.interact(my, ui.scope_id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); let sel = app.session.selection(); let pickadd = app.session.settings.pickadd; let Ok(d) = app.session.doc() else { return }; @@ -701,6 +705,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// 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(); properties_header(app, ui); @@ -757,7 +762,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.id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { act("setvar", json!({ "name": "CELTSCALE", "value": f })); } }); @@ -779,7 +784,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Transparency", |ui| unavailable(ui, "ByLayer")); prop_row(ui, "Thickness", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { act("setvar", json!({ "name": "THICKNESS", "value": f })); } }); @@ -805,7 +810,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Annotation scale", |ui| unavailable(ui, "1:1")); prop_row(ui, "Text height", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { act("setvar", json!({ "name": "TEXTSIZE", "value": f })); } }); @@ -888,7 +893,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::new(("properties_color", &ids)), + egui::Id::unique(("properties_color", &ids)), selected_color.unwrap_or(Color::ByLayer), resolved_color, &color, @@ -924,7 +929,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.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 })); } }); @@ -954,7 +959,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::new(("prop_tr", &ids)); + let tid = egui::Id::unique(("prop_tr", &ids)); let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { @@ -979,7 +984,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let angles = app.session.angle_settings(); let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option, bad: &mut Option, ids: &Vec| { prop_row(ui, label, |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with((key, ids)), shown), bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) { *set = Some(json!({ "handles": ids, key: f })); } }); @@ -987,22 +992,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { match &e.kind { EntityKind::Line(l) => { prop_row(ui, "Start X", |ui| { - if let Some(f) = number(edit_field(ui, ui.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.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.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.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] })); } }); @@ -1038,7 +1043,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Text(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.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 })); } }); @@ -1048,7 +1053,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::MText(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.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 })); } }); @@ -1084,7 +1089,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Dimension(dm) => { prop_row(ui, "Text override", |ui| { - if let Some(s) = edit_field(ui, ui.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 })); } }); @@ -1123,7 +1128,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, &p.name, |ui| { let picked = match (p.editable, p.choices.is_empty()) { (true, false) => pick_menu(ui, &p.value, p.choices.iter().cloned()), - (true, true) => edit_field(ui, ui.id().with(("dynprop", e.handle, &p.name)), p.value.clone()), + (true, true) => edit_field(ui, ui.scope_id().with(("dynprop", e.handle, &p.name)), p.value.clone()), (false, _) => { read_only(ui, &p.value); None diff --git a/crates/ui-egui/src/palettes/patterns.rs b/crates/ui-egui/src/palettes/patterns.rs index 6d628dc26..746cbd6a1 100644 --- a/crates/ui-egui/src/palettes/patterns.rs +++ b/crates/ui-egui/src/palettes/patterns.rs @@ -124,18 +124,19 @@ pub fn menu(ui: &mut egui::Ui, current: &str) -> Option { /// HATCHEDIT's pattern prompt): a click enters the pattern's name at the prompt. pub fn prompt_picker(app: &mut CadApp, ctx: &egui::Context) { let Some(Picker::HatchPattern(current)) = app.session.current_prompt().and_then(|p| p.picker) else { return }; - let filter_id = egui::Id::new("hatch_pattern_filter"); + let filter_id = egui::Id::unique("hatch_pattern_filter"); let mut filter: String = ctx.data_mut(|d| d.get_temp(filter_id)).unwrap_or_default(); let mut chosen = None; let area = ctx.content_rect(); egui::Window::new(crate::tl!("Hatch Pattern")) - .id(egui::Id::new("Hatch Pattern")) + .id(egui::Id::unique("Hatch Pattern")) .collapsible(true) .resizable(true) .default_size(vec2(5.0 * (CELL + GAP) + 12.0, 5.0 * (CELL + LABEL_H + GAP) + 40.0)) .default_pos(pos2(area.max.x - 5.0 * (CELL + GAP) - 330.0, area.min.y + 120.0)) .show(ctx, |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)) + .on_hover_text(crate::tl!("Search for pattern")); ui.add_space(4.0); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { chosen = grid(ui, ¤t, &filter); diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index f573f701e..a4cabe1fd 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -40,7 +40,7 @@ struct State { } fn id() -> egui::Id { - egui::Id::new("cadcraft-quickcalc") + egui::Id::unique("cadcraft-quickcalc") } /// A variable's value as typed into an expression: a number, or a point as `[x,y]`. @@ -208,6 +208,15 @@ pub fn open(app: &mut CadApp) -> Result { crate::docking::command(app, false, &json!({ "operation": "float", "panel": panel.id(), "rect": rect })) } +/// The unit-conversion value row. The visible "Value" label names the numeric field (its +/// AccessKit label becomes "Value"), and the placeholder repeats the meaning instead of the +/// unexplained "1" example. +fn value_row(ui: &mut egui::Ui, value: &mut String) { + let label = ui.label(crate::tl!("Value")); + ui.add(egui::TextEdit::singleline(value).hint_text(crate::tl!("Value")).desired_width(120.0)).labelled_by(label.id); +} + +/// Quick Calculator dock body: expression entry with evaluation, variables and unit conversion. pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default(); @@ -320,8 +329,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.end_row(); } - ui.label(crate::tl!("Value")); - ui.add(egui::TextEdit::singleline(&mut st.conv_value).hint_text("1").desired_width(120.0)); + value_row(ui, &mut st.conv_value); ui.end_row(); if ui.button(crate::tl!("Convert")).clicked() { st.convert(app); @@ -365,3 +373,27 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.ctx().data_mut(|d| d.insert_temp(id(), st)); } + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + use super::value_row; + + /// The conversion value field is named by its visible label under bundled fonts only, so a + /// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name. + #[test] + fn value_field_is_named_by_its_visible_label() { + let mut harness = Harness::builder().with_size(egui::vec2(320.0, 80.0)).build_ui(|ui| { + let mut value = String::new(); + value_row(ui, &mut value); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::TextInput, "Value"); + assert!(field.accesskit_node().labelled_by().next().is_some(), "named by the visible Value label"); + assert_eq!(field.accesskit_node().placeholder(), Some("Value")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } +} diff --git a/crates/ui-egui/src/panel_state.rs b/crates/ui-egui/src/panel_state.rs new file mode 100644 index 000000000..39ad79657 --- /dev/null +++ b/crates/ui-egui/src/panel_state.rs @@ -0,0 +1,76 @@ +//! Additive migration of persisted panel sizes from the 0.36 root-id key to 0.37's +//! `parent_ui.scope_id().with_salt(name)` key. +//! +//! CADCraft disables egui memory persistence today, but the app's own saved workspace or a host +//! that enables persistence must not lose a resized panel once. The old key is retained for +//! rollback; a value already stored under the new key always wins. + +/// The top-level panel id salts used by CADCraft. Keep these strings stable: they are names in +/// saved test fixtures and in `ui.elements` expectations, not disposable implementation details. +pub(crate) const PANEL_IDS: &[&str] = &["cc_title_toolbar", "cc_menubar", "cc_status", "cc_file_tabs", "cc_toolsets_strip", "cc_palettes"]; + +/// Copy an old-key `PanelState` to the 0.37 key before panels are shown. Call once on the first +/// pass, while the root `Ui` scope id is available. +pub(crate) fn migrate(ui: &egui::Ui) { + let ctx = ui.ctx(); + let parent = ui.scope_id(); + for name in PANEL_IDS { + let old = egui::Id::unique(name); + let new = parent.with_salt(egui::IdSalt::new(name)); + if old == new { + continue; + } + let Some(state) = ctx.data_mut(|data| data.get_persisted::(old)) else { + continue; + }; + ctx.data_mut(|data| { + if data.get_persisted::(new).is_none() { + data.insert_persisted(new, state); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test panel state `width` points wide and 26 points high. + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 26.0)) } + } + + /// `migrate` copies the old root-id panel state into the 0.37 scoped key and leaves the old key in place for rollback. + #[test] + fn old_key_is_copied_to_the_scoped_key_once() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut new_key = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate(ui); + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + new_key = Some(new); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(41.0)); + }); + output.textures_delta.clear(); + let new_key = new_key.expect("a root Ui"); + assert_eq!(egui::PanelState::load(&ctx, new_key).map(|state| state.size().x), Some(41.0)); + assert_eq!(egui::PanelState::load(&ctx, old).map(|state| state.size().x), Some(41.0), "the old key is kept for rollback"); + } + + /// `migrate` never replaces a value already stored under the new scoped key. + #[test] + fn a_new_key_is_never_overwritten() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + ctx.data_mut(|data| data.insert_persisted(new, state(64.0))); + migrate(ui); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(64.0)); + }); + output.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index 83a4328bd..ade7a39fa 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -392,7 +392,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); lock(painter, Rect::from_min_size(r.min + vec2(3.0, (r.height() - 13.0) / 2.0), vec2(13.0, 13.0)), col); painter.galley(r.min + vec2(19.0, 2.0), galley, col); - let resp = ui.interact(r, ui.id().with(("dimcon", c.id)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("dimcon", c.id)), Sense::click()); if resp.double_clicked() { act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); } @@ -464,7 +464,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_rgb(0x6e, 0x75, 0x80).gamma_multiply(alpha)), egui::StrokeKind::Inside); for (i, (kind, id)) in kinds.iter().take(16).enumerate() { let cr = Rect::from_min_size(r.min + vec2(2.0 + i as f32 * cell, 2.0), vec2(cell, cell)); - let resp = ui.interact(cr, ui.id().with(("cbar", h.0, *id)), Sense::hover()); + let resp = ui.interact(cr, ui.scope_id().with(("cbar", h.0, *id)), Sense::hover()); if resp.hovered() { painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); } @@ -475,7 +475,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter if let Some((text, col)) = status { let sr = Rect::from_min_max(pos2(r.right() - strip - 1.0, r.top() + 3.0), pos2(r.right() - 2.0, r.bottom() - 3.0)); painter.rect_filled(sr, 2.0, col.gamma_multiply(alpha.max(0.5))); - ui.interact(sr.expand(2.0), ui.id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); + ui.interact(sr.expand(2.0), ui.scope_id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); } } // The drawing's constraint state. @@ -498,11 +498,12 @@ fn act_take(app: &mut CadApp, act: Option, ui: &mut egui::Ui) -> Option<()> // The inline expression editor. let (name, mut buf, at) = app.canvas.param.editing.clone()?; let mut done: Option = None; - egui::Area::new(egui::Id::new("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("{name} =")); - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::new("dimcon_edit_te"))); + let label = ui.label(format!("{name} =")); + let r = + ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::unique("dimcon_edit_te"))).labelled_by(label.id); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -571,14 +572,14 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) .and_then(|v| v.get("parameters").cloned()) .and_then(|v| serde_json::from_value(v).ok()) .unwrap_or_default(); - let filter_id = egui::Id::new("par_filter"); + let filter_id = egui::Id::unique("par_filter"); let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); let mut action: Option = None; // The name being renamed in place: (old name, text typed so far). - let rename_id = egui::Id::new("par_rename"); + let rename_id = egui::Id::unique("par_rename"); let mut renaming = ctx.data_mut(|d| d.get_temp::<(String, String)>(rename_id)); egui::Window::new(crate::tl!("Parameters Manager")) - .id(egui::Id::new("Parameters Manager")) + .id(egui::Id::unique("Parameters Manager")) .open(open) .default_size(vec2(560.0, 300.0)) .resizable(true) @@ -592,7 +593,8 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)) + .accessible_name(crate::tl!("Search for parameter")); }); }); ui.separator(); @@ -609,6 +611,7 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) ui.label(egui::RichText::new(crate::i18n::t(title)).color(t.text_dim).small()); ui.end_row(); for r in group { + let mut name_label = None; if let Some((old, buf)) = renaming.as_mut().filter(|(old, _)| *old == r.name) { if let Some(name) = rename_field(ui, rename_id.with("te"), old, buf) { if !name.is_empty() && name != *old { @@ -616,11 +619,15 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) } renaming = None; } - } else if ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())).double_clicked() { - renaming = Some((r.name.clone(), r.name.clone())); + } else { + let label = ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())); + if label.double_clicked() { + renaming = Some((r.name.clone(), r.name.clone())); + } + name_label = Some(label.id); } - let id = egui::Id::new(("par_expr", &r.name)); - if let Some(v) = expr_field(ui, id, &r.expr) { + let id = egui::Id::unique(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr, name_label) { action = Some(json!({ "name": r.name, "expr": v })); } match (&r.value, &r.error) { @@ -662,8 +669,11 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) /// The in-place name editor: focused with the old name selected when it opens, so typing goes /// here and not to the command line. `Some` when it closes: the new name on Enter, `""` otherwise. fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> Option { + // The id must be the same on every frame; `Id::unique` hashes its source deterministically. + // A `TextEdit` stores its state while it renders, so after requesting focus here the + // select-all range is loaded from that state and takes effect on the next frame. let opening = egui::TextEdit::load_state(ui.ctx(), id).is_none(); - let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)); + let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)).accessible_name(crate::tl!("Name")); if opening { resp.request_focus(); if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { @@ -680,9 +690,16 @@ fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> } /// A text field committing on Enter / focus loss when changed. -pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the id of the visible label beside the field, so a screen reader reads the field as +/// that label instead of an unnamed text box. +pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: Option) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(150.0)); + let resp = match label { + Some(label) => resp.labelled_by(label), + None => resp.accessible_name(crate::tl!("Value")), + }; // Focus from memory: `Response::has_focus` is false while the OS window is unfocused, // which dropped the pending edit (matches `palettes::edit_field`). if ui.memory(|m| m.has_focus(id.with("te"))) { @@ -713,14 +730,14 @@ mod tests { fn frame(ctx: &egui::Context, focused: bool, events: Vec) -> Option { let mut out = None; let input = egui::RawInput { focused, events, ..Default::default() }; - ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::new("e"), "1")).drop_without_applying_deltas(); + ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::unique("e"), "1", None)).drop_without_applying_deltas(); out } #[test] fn pending_expression_survives_window_blur() { let ctx = egui::Context::default(); - let id = egui::Id::new("e"); + let id = egui::Id::unique("e"); frame(&ctx, true, vec![]); ctx.memory_mut(|m| m.request_focus(id.with("te"))); frame(&ctx, true, vec![egui::Event::Text("+2".into())]); diff --git a/crates/ui-egui/src/plotstyles.rs b/crates/ui-egui/src/plotstyles.rs index bc3df8b0f..54727162c 100644 --- a/crates/ui-egui/src/plotstyles.rs +++ b/crates/ui-egui/src/plotstyles.rs @@ -20,11 +20,11 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// Lineweights offered for a style, in millimetres. @@ -41,6 +41,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Plot Style Manager window; returns the command (`stylesmanager` or `pagesetup`) to run after drawing. fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let title = crate::tl!("Plot Style Manager"); @@ -65,7 +66,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let mut new_style = temp::(ctx, "pstyle_new").unwrap_or_default(); let mut action: Action = None; let set = |table: &str, style: Value| Some(("stylesmanager", json!({ "action": "set", "name": table, "style": style }))); - egui::Window::new(title).id(egui::Id::new("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { ui.label(crate::tl!("Plot style table")); egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| { @@ -197,8 +198,8 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { if named { ui.separator(); ui.horizontal(|ui| { - ui.label(crate::tl!("New style")); - ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)); + let label = ui.label(crate::tl!("New style")); + ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)).labelled_by(label.id); if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() { action = set(&table.name, json!({ "name": new_style.trim() })); new_style.clear(); diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 91f6740ea..6eb7c45b9 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -59,9 +59,10 @@ fn pretty(k: &str) -> String { out } +/// Quick Select window; caches `qselect.info` per drawing, apply-to choice and object type, then runs the selection with the chosen criteria. pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("qselect_state"); + let sid = egui::Id::unique("qselect_state"); let mut st: QsState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let key = app.session.state().map(|s| std::sync::Arc::as_ptr(&s.doc) as usize).unwrap_or(0); let want = (key, st.apply_selection, st.ty.clone()); @@ -77,7 +78,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut run: Option = None; let mut close = false; egui::Window::new(crate::tl!("Quick Select")) - .id(egui::Id::new("Quick Select")) + .id(egui::Id::unique("Quick Select")) .open(open) .collapsible(false) .resizable(false) @@ -141,7 +142,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }); ui.end_row(); - ui.label(crate::tl!("Value:")); + let value_label = ui.label(crate::tl!("Value:")); let choices: Vec = match st.property.as_str() { "color" => { ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(ToString::to_string).collect() @@ -152,7 +153,8 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { _ => Vec::new(), }; ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })) + .labelled_by(value_label.id); if !choices.is_empty() { ui.menu_button("▼", |ui| { egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { @@ -228,10 +230,11 @@ struct QpState { at: Option, } +/// Quick Properties numeric row: an expression or distance field labelled by `label`; commits only a finite value under `key` into `set`. fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { - ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); + let label = ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); let s = format!("{v:.4}"); - if let Some(n) = crate::parametric::expr_field(ui, id, &s) + if let Some(n) = crate::parametric::expr_field(ui, id, &s, Some(label.id)) && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) { *set = Some((key.to_string(), json!(f))); @@ -247,7 +250,7 @@ fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { /// The Quick Properties palette, near where the selection was made. pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { - let sid = egui::Id::new("qp_state"); + let sid = egui::Id::unique("qp_state"); let sel = app.session.selection(); let active = app.session.settings.qpmode && !sel.is_empty() @@ -299,7 +302,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let angles = app.session.angle_settings(); let mut close = false; egui::Window::new(title) - .id(egui::Id::new(("quick_properties", &st.sel))) + .id(egui::Id::unique(("quick_properties", &st.sel))) .default_pos(at) .collapsible(false) .resizable(false) @@ -309,7 +312,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim)); let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", &ids)), cur, true, 150.0) { set = Some(("color".into(), json!(c.name()))); } ui.end_row(); @@ -334,7 +337,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } }); ui.end_row(); - let id = |k: &str| egui::Id::new(("qp", k, &ids)); + let id = |k: &str| egui::Id::unique(("qp", k, &ids)); match &kind { Some(EntityKind::Line(l)) => { let dl = l.b.xy() - l.a.xy(); @@ -369,8 +372,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); } Some(EntityKind::Text(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -378,8 +381,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Rotation", id("rot"), angles.shown(tx.rotation).to_degrees(), &mut set, "rotation"); } Some(EntityKind::MText(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -391,8 +394,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); } Some(EntityKind::Dimension(dm)) => { - ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { + let label = ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text, Some(label.id)) { set = Some(("textOverride".into(), json!(v))); } ui.end_row(); diff --git a/crates/ui-egui/src/spacemouse.rs b/crates/ui-egui/src/spacemouse.rs index 8f2c90e12..3a9e2ba32 100644 --- a/crates/ui-egui/src/spacemouse.rs +++ b/crates/ui-egui/src/spacemouse.rs @@ -132,8 +132,9 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result { Ok(json!({"center": [center.x, center.y], "height": height})) } +/// 3D Mouse window showing connection status, detected device/detail and the navigation settings, with a Calibrate action. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::new("spacemouse")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::unique("spacemouse")).open(open).resizable(false).show(ctx, |ui| { ui.label(crate::i18n::t(app.spacemouse.status.message())); if let Some(device) = &app.spacemouse.device { ui.label(device); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index fe1fd43fb..9aeac31a1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -307,8 +307,10 @@ pub fn mono() -> FontId { FontId::new(12.0, FontFamily::Monospace) } -/// Load a system UI font at run time when one is installed (not bundled), else keep egui's; -/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface +/// Load a system UI font at run time when one is installed (not bundled), else keep egui's +/// bundled bounded Latin/icon faces (`default_fonts` + `monochrome_emoji_fonts`, enabled in the +/// workspace manifest for native and web); then CJK fonts after the Latin ones, so Latin text +/// keeps its look, in every interface /// language: file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a /// Japanese one, so names take their own glyph forms; in an Arabic interface an installed @@ -317,6 +319,7 @@ pub fn mono() -> FontId { /// interface adds a Hangul font only when the CJK font found doesn't cover Hangul /// ([`korean_font`]). pub fn install_fonts(ctx: &egui::Context, language: &str) { + crate::mtext_editor::install_diameter_glyph(ctx); let mut fonts = egui::FontDefinitions::default(); let mut added: Vec<(String, std::sync::Arc)> = Vec::new(); #[cfg(not(target_arch = "wasm32"))] @@ -341,6 +344,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-ui".into()); } + // Keep Hack first in Monospace, then use the system face for glyphs Hack lacks. + if let Some(f) = fonts.families.get_mut(&FontFamily::Monospace) { + let at = f.iter().position(|name| name == "Hack").map_or(0, |i| i + 1); + f.insert(at, "system-ui".into()); + } break; } } @@ -388,6 +396,16 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { } } } + // `default_fonts` supplies Ubuntu-Light (Proportional) and Hack (Monospace). Hack also + // carries the modifier symbols (⇧) that proportional shortcut labels use; keep it as a + // bounded late fallback when Ubuntu-Light lacks one. The monochrome emoji face (enabled by + // `monochrome_emoji_fonts`) supplies ⌘ in both families. + if fonts.font_data.contains_key("Hack") + && let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) + && !f.iter().any(|name| name == "Hack") + { + f.push("Hack".into()); + } ctx.set_fonts(fonts); } diff --git a/crates/ui-egui/src/viewcube.rs b/crates/ui-egui/src/viewcube.rs index 6f3ec0a3d..8f96e82fe 100644 --- a/crates/ui-egui/src/viewcube.rs +++ b/crates/ui-egui/src/viewcube.rs @@ -80,7 +80,7 @@ impl Part { /// Paint the ViewCube's ring, compass letters, Top face and Home button, and handle the pointer. /// The canvas paints the WCS pill itself; this returns the pointer's part for its highlight. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { - let resp = ui.interact(l.bounds, ui.id().with("viewcube"), Sense::click_and_drag()); + let resp = ui.interact(l.bounds, ui.scope_id().with("viewcube"), Sense::click_and_drag()); let part = resp.hover_pos().and_then(|p| l.part_at(p)); let p = ui.painter_at(l.bounds.union(l.pill)); let ring_col = if part == Some(Part::Ring) { Color32::from_rgb(0x5c, 0x67, 0x7c) } else { Color32::from_rgb(0x48, 0x50, 0x5c) }; @@ -132,7 +132,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { let _ = app.run(id, params); } // The WCS pill (painted by the canvas) opens the coordinate-system menu. - let pill = ui.interact(l.pill, ui.id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); + let pill = ui.interact(l.pill, ui.scope_id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); let pill = pill.on_hover_text("Coordinate system"); ucs_menu(app, egui::Popup::menu(&pill)); } @@ -164,8 +164,8 @@ pub fn ucs_arms(canvas: Rect) -> [Rect; 2] { pub fn ucs_icon(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let [x, y] = ucs_arms(canvas); - let rx = ui.interact(x, ui.id().with("ucs_x"), Sense::click_and_drag()); - let ry = ui.interact(y, ui.id().with("ucs_y"), Sense::click_and_drag()); + let rx = ui.interact(x, ui.scope_id().with("ucs_x"), Sense::click_and_drag()); + let ry = ui.interact(y, ui.scope_id().with("ucs_y"), Sense::click_and_drag()); let resp = (rx | ry).on_hover_cursor(CursorIcon::PointingHand).on_hover_text("UCS icon: World Coordinate System"); if resp.hovered() { let o = pos2(x.left() + 8.0, x.center().y); diff --git a/crates/ui-egui/src/vpscale.rs b/crates/ui-egui/src/vpscale.rs index 8f19c85e3..304797c19 100644 --- a/crates/ui-egui/src/vpscale.rs +++ b/crates/ui-egui/src/vpscale.rs @@ -120,7 +120,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let p = ui.painter().clone(); // Lock toggle. let lr = Rect::from_min_size(pos2(right - 20.0, cy - 9.0), vec2(18.0, 18.0)); - let lresp = ui.interact(lr, ui.id().with("vp_lock"), Sense::click()); + let lresp = ui.interact(lr, ui.scope_id().with("vp_lock"), Sense::click()); if lresp.hovered() { p.rect_filled(lr, 3.0, t.control_hover.gamma_multiply(0.5)); } @@ -138,7 +138,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let g = p.layout_no_wrap(text, crate::theme::small(), if vp.locked { t.text_dim } else { t.text }); let w = g.size().x + 22.0; let sr = Rect::from_min_max(pos2(lr.left() - 4.0 - w, cy - 9.0), pos2(lr.left() - 4.0, cy + 9.0)); - let sresp = ui.interact(sr, ui.id().with("vp_scale"), Sense::click()); + let sresp = ui.interact(sr, ui.scope_id().with("vp_scale"), Sense::click()); p.rect_filled(sr, 3.0, if sresp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) }); p.galley(pos2(sr.left() + 6.0, sr.center().y - g.size().y / 2.0), g, t.text); let (ax, ay) = (sr.right() - 9.0, sr.center().y); diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index fb9cb9ef1..f142ab1f2 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -77,7 +77,7 @@ struct Row { fn rows(app: &CadApp, ui: &egui::Ui) -> Vec { let Ok(st) = app.session.state() else { return Vec::new() }; let key = (st.uid, st.revision); - let id = egui::Id::new("xref-rows"); + let id = egui::Id::unique("xref-rows"); if let Some((k, rows)) = ui.data(|d| d.get_temp::<((u64, u64), Vec)>(id)) && k == key { @@ -129,8 +129,8 @@ enum Act { pub fn section(app: &mut CadApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); let rows = rows(app, ui); - let sel_id = egui::Id::new("xref-selected"); - let path_id = egui::Id::new("xref-path-edit"); + let sel_id = egui::Id::unique("xref-selected"); + let path_id = egui::Id::unique("xref-path-edit"); let mut selected: Option = ui.data(|d| d.get_temp(sel_id)); if !selected.as_ref().is_some_and(|s| rows.iter().any(|r| &r.name == s)) { selected = rows.first().map(|r| r.name.clone()); @@ -245,10 +245,10 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(8.0); - ui.label(crate::tl!("Saved Path")); + let label = ui.label(crate::tl!("Saved Path")); let mut text: String = ui.data(|d| d.get_temp(path_id)).unwrap_or_else(|| r.path.clone()); let w = (ui.available_width() - 70.0).max(60.0); - let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)).labelled_by(label.id); 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 { diff --git a/crates/ui-egui/tests/accessibility_names.rs b/crates/ui-egui/tests/accessibility_names.rs new file mode 100644 index 000000000..2b5ff8cef --- /dev/null +++ b/crates/ui-egui/tests/accessibility_names.rs @@ -0,0 +1,100 @@ +//! Semantic accessibility: controls expose an accessible name, not only a placeholder, and the +//! AccessKit root carries the language the interface is shown in. +//! +//! `hint_text` becomes an AccessKit placeholder, so a screen reader sees an unnamed text input +//! unless the widget is associated with a visible label (`labelled_by`) or given a name. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, i18n, menus}; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +/// A 1400×900 kittest harness whose frame runs `logic` and then `ui` on the supplied app. +fn harness(app: CadApp) -> Harness<'static, CadApp> { + Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ) +} + +/// Every editable text input must advertise a non-empty accessible name: its own label or the +/// visible label it is associated with. A placeholder alone is not a name. +fn unnamed_text_inputs(h: &Harness<'_, CadApp>) -> Vec { + let text_inputs = h.query_all_by_role(Role::TextInput).chain(h.query_all_by_role(Role::MultilineTextInput)); + text_inputs + .filter(|node| { + let node = node.accesskit_node(); + node.label().as_deref().is_none_or(str::is_empty) && node.labelled_by().next().is_none() + }) + .map(|node| { + let value = node.accesskit_node().value().unwrap_or_default().to_string(); + let placeholder = node.accesskit_node().placeholder().unwrap_or_default().to_string(); + format!("value={value:?} placeholder={placeholder:?}") + }) + .collect() +} + +/// The Options ▸ Files autosave path field is labelled by its visible row label, and no text input in the dialog is unnamed. +#[test] +fn options_autosave_path_has_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + // The path field lives on Open and Save, not on the default Display tab. + app.ui.dialog = Some("options:files".into()); + let h = harness(app); + let name = "Automatic save file location"; + let field = h.get_by_role_and_label(Role::TextInput, name); + let labelled_by: Vec<_> = field.accesskit_node().labelled_by().map(|node| node.id()).collect(); + assert_eq!(labelled_by.len(), 1, "the field is labelled by the visible row label"); + let label = h.get_all_by_role(Role::Label).find(|node| node.accesskit_node().id() == labelled_by[0]).expect("the referenced label node"); + assert_eq!(label.accesskit_node().value().as_deref(), Some(name)); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in OPTIONS: {unnamed:?}"); +} + +/// Every interactive control on every Options tab is named by the visible label beside it. The +/// harness's own strict accessibility check already runs while it settles; this test makes the +/// tab-by-tab coverage explicit and reports the offending tab in its own assertion. +#[test] +fn every_options_tab_names_its_controls() { + for tab in ["display", "files", "drafting", "selection", "preferences", "profiles"] { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + app.ui.dialog = Some(format!("options:{tab}")); + let h = harness(app); + let unnamed = h.unnamed_widgets(); + assert!(unnamed.is_empty(), "unnamed controls on Options {tab}: {unnamed:?}"); + } +} + +/// The Parameters Manager's expression fields are the concrete unnamed fields the review found; +/// each one is named by the parameter's name label in the same row. +#[test] +fn parameters_manager_fields_have_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("parameters", json!({ "name": "user1", "expr": "4" })).unwrap(); + app.run("parameters", json!({ "name": "user2", "expr": "user1*2" })).unwrap(); + app.ui.dialog = Some("parameters".into()); + let h = harness(app); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in the Parameters Manager: {unnamed:?}"); + let labels: Vec = h.query_all_by_role(Role::TextInput).filter_map(|node| node.accesskit_node().label()).collect(); + assert!(labels.iter().any(|label| label == "user1"), "the first expression field is named by its parameter: {labels:?}"); + assert!(labels.iter().any(|label| label == "user2"), "the second expression field is named by its parameter: {labels:?}"); +} + +/// The AccessKit root names the language the interface is actually shown in, so a screen reader +/// switches voice instead of reading a translated interface with the OS default. +#[test] +fn accesskit_root_carries_the_interface_language() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = i18n::Preference::German; + let h = harness(app); + assert_eq!(h.state().language(), "de"); + assert_eq!(h.root().accesskit_node().data().language(), Some("de"), "the root node carries the shown language"); +} diff --git a/crates/ui-egui/tests/it/animation_effects.rs b/crates/ui-egui/tests/it/animation_effects.rs index 926b084e5..ba18afb72 100644 --- a/crates/ui-egui/tests/it/animation_effects.rs +++ b/crates/ui-egui/tests/it/animation_effects.rs @@ -23,8 +23,8 @@ fn frame( let mut seen = None; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); - seen = Some(look(ui.ctx())); app.ui(ui); + seen = Some(look(ui.ctx())); }); out.textures_delta.clear(); // no renderer here (seen.unwrap(), out.shapes) @@ -101,6 +101,7 @@ fn wheel_scrolling_glides_only_while_animation_effects_show() { let notches = || { vec![egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, @@ -130,7 +131,13 @@ fn wheel_zoom(app: &mut CadApp, modifiers: egui::Modifiers) -> Vec { frame(app, &ctx, vec![egui::Event::PointerMoved(over)], |_| ()); } let before = height(app); - let notch = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }; + let notch = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers, + }; let mut events = vec![notch]; (0..40) .map(|_| { diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 1cffab495..18410ceae 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -325,7 +325,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { .unwrap(); h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); - let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::::new())); + let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::::new())); let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); @@ -392,7 +392,7 @@ fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); h.run_steps(4); let bar = |h: &Harness<'_, CadApp>| { - DockArea::::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking")) + DockArea::::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking")) .into_iter() .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) .map(|(_, rect)| rect) diff --git a/crates/ui-egui/tests/it/hidden_command_line.rs b/crates/ui-egui/tests/it/hidden_command_line.rs index 58427f625..0da43aa42 100644 --- a/crates/ui-egui/tests/it/hidden_command_line.rs +++ b/crates/ui-egui/tests/it/hidden_command_line.rs @@ -18,7 +18,7 @@ fn call(app: &mut CadApp, ctx: &egui::Context, method: &str, params: Value) -> V fn frame(app: &mut CadApp, ctx: &egui::Context) { let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(ctx, &mut raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/tests/it/lisp_files.rs b/crates/ui-egui/tests/it/lisp_files.rs index abd7f6a81..42d6d6e7e 100644 --- a/crates/ui-egui/tests/it/lisp_files.rs +++ b/crates/ui-egui/tests/it/lisp_files.rs @@ -23,7 +23,10 @@ fn a_long_routine_runs_across_frames() { let ctx = egui::Context::default(); let mut frames = 0; while app.session.lisp_running() && frames < 1000 { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); // no renderer here frames += 1; } diff --git a/crates/ui-egui/tests/it/menubar_hover.rs b/crates/ui-egui/tests/it/menubar_hover.rs index 2a0ec4da9..798776268 100644 --- a/crates/ui-egui/tests/it/menubar_hover.rs +++ b/crates/ui-egui/tests/it/menubar_hover.rs @@ -100,7 +100,8 @@ fn clicking_the_open_title_closes_it() { fn submenus_open_and_close_with_their_menu() { let mut harness = harness(); click_title(&mut harness, "View"); - harness.get_by_label("Zoom ⏵").hover(); + // egui 0.37 names the submenu button "Zoom"; the arrow is painted, not part of the label. + harness.get_by_role_and_label(Role::Button, "Zoom").hover(); harness.run_steps(3); assert!(shown(&harness, IN_ZOOM), "pointing at Zoom opens its submenu"); diff --git a/crates/ui-egui/tests/it/options_dialog.rs b/crates/ui-egui/tests/it/options_dialog.rs index eb9cdeaea..be6869e4a 100644 --- a/crates/ui-egui/tests/it/options_dialog.rs +++ b/crates/ui-egui/tests/it/options_dialog.rs @@ -150,10 +150,14 @@ fn hostile_saved_profiles_are_ignored() { fn model_background_reaches_the_drawing_area_tokens() { let mut a = app(); let ctx = egui::Context::default(); - a.logic(&ctx); + let frame = |a: &mut CadApp| { + let mut out = ctx.run_ui(Default::default(), |ui| a.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut a); assert_eq!(Tokens::get().canvas, Tokens::DARK.canvas); a.run("options", json!({"modelBackground": "#ffffff", "paperBackground": "#202020"})).unwrap(); - a.logic(&ctx); + frame(&mut a); let t = Tokens::get(); assert_eq!(t.canvas, Color32::WHITE); assert_eq!(t.paper, Color32::from_gray(0x20)); @@ -161,7 +165,7 @@ fn model_background_reaches_the_drawing_area_tokens() { assert_ne!(t.grid_minor, Tokens::DARK.grid_minor); assert_eq!(t.chrome, Tokens::DARK.chrome, "the interface keeps its theme"); a.run("options", json!({"reset": true})).unwrap(); - a.logic(&ctx); + frame(&mut a); assert_eq!(Tokens::get(), Tokens::DARK); } @@ -234,7 +238,7 @@ fn selection_highlight_settings_set_save_and_show_in_the_selection_tab() { assert!(!text.contains(label), "{label} offered"); } // Visual Effect Settings: the selection area and the preview filter. - ctx.data_mut(|d| d.insert_temp(egui::Id::new("cc_options_visual_effects"), true)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("cc_options_visual_effects"), true)); let text = render(&mut a, &ctx); for label in [ "Selection Area Effect", diff --git a/crates/ui-egui/tests/it/parameters_rename.rs b/crates/ui-egui/tests/it/parameters_rename.rs index 0754bbcb0..9530bbd17 100644 --- a/crates/ui-egui/tests/it/parameters_rename.rs +++ b/crates/ui-egui/tests/it/parameters_rename.rs @@ -3,9 +3,10 @@ use cadcraft_engine::Session; use cadcraft_ui_egui::{CadApp, Services}; +use egui::accesskit::Role; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{By, Queryable}; use serde_json::json; fn harness() -> Harness<'static, CadApp> { @@ -26,7 +27,11 @@ fn harness() -> Harness<'static, CadApp> { } fn double_click_label(h: &mut Harness<'_, CadApp>, label: &str) { - let pos = h.get_by_label(label).rect().center(); + // Click the visible name label itself. The expression field beside it is `labelled_by` that + // label, and kittest's `get_by_label` deliberately returns the labelled widget (the field), + // so a plain `get_by_label` would double-click the wrong node. `include_labels` plus the + // Label role selects the row's name text, keeping this a real pointer/keyboard test. + let pos = h.get(By::new().role(Role::Label).label(label).include_labels()).rect().center(); h.event(Event::PointerMoved(pos)); h.run_steps(2); for _ in 0..2 { @@ -47,6 +52,7 @@ fn parameters(h: &Harness<'_, CadApp>) -> Vec<(String, String)> { fn double_click_types_a_new_name_and_enter_renames() { let mut h = harness(); double_click_label(&mut h, "user1"); + assert!(h.get_by_role_and_label(Role::TextInput, "Name").is_focused(), "the rename field has the keyboard focus"); h.event(Event::Text("width".into())); h.run_steps(2); assert_eq!(h.state().cmd.buffer, "", "the command line got the keys"); diff --git a/crates/ui-egui/tests/it/properties_units.rs b/crates/ui-egui/tests/it/properties_units.rs index 5b9eb31c9..596af84c3 100644 --- a/crates/ui-egui/tests/it/properties_units.rs +++ b/crates/ui-egui/tests/it/properties_units.rs @@ -4,7 +4,7 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; -use cadcraft_ui_egui::{CadApp, Services}; +use cadcraft_ui_egui::{CadApp, Services, theme}; use egui::{Key, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{Queryable, by}; @@ -16,7 +16,9 @@ fn palettes(lunits: i64) -> Harness<'static, CadApp> { app.run("setvar", json!({ "name": "LUNITS", "value": lunits })).unwrap(); let h = app.run("text", json!({ "at": [0, 0], "text": "Note", "height": 2.5 })).unwrap(); app.run("select", json!({ "handles": [h["handle"]] })).unwrap(); - let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws palettes + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/it/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs index 2c6bc2ab8..76cb38979 100644 --- a/crates/ui-egui/tests/it/shared_controls.rs +++ b/crates/ui-egui/tests/it/shared_controls.rs @@ -116,7 +116,9 @@ fn drafting_palette_pointer_and_keyboard_start_the_same_commands() { #[test] fn right_palettes_collapse_arrow_hides_menu() { - let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws the palette + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/options_followups.rs b/crates/ui-egui/tests/options_followups.rs index 0f470baeb..9c7327905 100644 --- a/crates/ui-egui/tests/options_followups.rs +++ b/crates/ui-egui/tests/options_followups.rs @@ -117,6 +117,8 @@ fn interface_scale_in_options() { assert_eq!(r["interfaceScale"], json!(1.5)); let ctx = egui::Context::default(); frame(&mut a, &ctx, Vec::new(), 0.0); + // egui applies a newly requested zoom factor at the start of the next pass. + frame(&mut a, &ctx, Vec::new(), 0.1); assert_eq!(ctx.zoom_factor(), 1.5, "applied as egui's zoom factor"); // Refused all or nothing. @@ -143,13 +145,14 @@ fn interface_scale_in_options() { // Reset brings back the system's own size. assert_eq!(a.run("options", json!({"reset": true})).unwrap()["interfaceScale"], json!(1.0)); - frame(&mut a, &ctx, Vec::new(), 0.1); + frame(&mut a, &ctx, Vec::new(), 0.2); + frame(&mut a, &ctx, Vec::new(), 0.3); assert_eq!(ctx.zoom_factor(), 1.0); // The Display tab shows it. a.run("options", json!({"interfaceScale": 1.25})).unwrap(); a.ui.dialog = Some("options:display".into()); - frame(&mut a, &ctx, Vec::new(), 0.2); - let text = frame(&mut a, &ctx, Vec::new(), 0.3); + frame(&mut a, &ctx, Vec::new(), 0.4); + let text = frame(&mut a, &ctx, Vec::new(), 0.5); assert!(text.contains("Interface scale") && text.contains("125 %"), "{text}"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 63e565eb6..1c3f30626 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | | `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | -| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | +| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | @@ -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). | +| `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.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. 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. | diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..9859dfe1a --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1475 @@ +# CADCraft — egui 0.37 migration plan + +Current reconciliation (2026-10-25): the local tree now incorporates main `e86d895ba1086a7efc2a83048348f89098282d08`. Managed run `egui037-cad-maine86-ci-20261025-0825` passed all seven CI stages: 2,515 passing test executions, zero failures and one ignored across 103 summaries. Independent review verified preservation of both parents and dependency pins; the compare-export regression now verifies document-epoch advancement. Earlier validation counts below describe prior candidates. Native/browser GUI, platform and paired performance checks remain open. Publication requires explicit approval; human review and human merge are required. + +**Status (2026-10-25):** reconciled main `b90165666917ec3ac6c308c2ea1c55bb6ea8317d` and passed all 7 mandatory CI stages. Managed job `egui037-cadcraft-main-refresh-ci-20261025-0615` recorded 2,451 passing test executions, zero failures and 1 ignored. The shared-docking adoption uses the reviewed craft-ui revision `650b1c0b058017c4eb3af06d5b39586d6732e07b`; the lock changed only that package and its dependency list. This revision is reachable from the published craft-libs migration branch. Native/platform GUI and IME workflows, final packages and paired app performance remain open. Earlier source identities and counts below are historical. + +Current landing status, 2026-10-25: the implementation integrates main `fcfdf33001cf2462bdafb6e1237becd8ef0ea1cb` and selects egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, shared UI `25da1314d443171b4adeaad2cd9454b273f35c6f` and independent PDF/document-path revisions in the lockfile. Full managed CI0300 passed all seven stages, with 2,448 passing tests, no failures and one ignored across 103 summaries. Fresh review0320 verified the tested source, all seven latest-main paths and all 13 new source files. + +The review also identified inherited upstream issues in MEASUREINIT reset/web startup and PDF font hardening; these are carried explicitly in the review packet. The egui licence-source URL has been updated to the selected revision; the font bytes are unchanged. Final notice validation is pending. Current-source Windows/macOS/FreeBSD, visible GUI, native IME and performance acceptance are not established by the Linux CI result. Human review and human merge are required. + +The original planning and research record follows; its baseline pins and planning-only status are historical. + +Status: **planning only**. This document changes no production code, manifest, lockfile, +branch, tag, PR or release. No build, test or GUI run was performed while writing it. +Every future push or new PR requires fresh, explicit human approval after the exact +contents and validation results are shown; nothing in this document grants that approval. +Every future host build/test and owned-VM command goes through +`maintenance/task.py run/status/finish` with its own task, source and evidence. + +Scope: the `storytold/cadcraft` checkout at +`/home/codex/work/CraftApps/egui037-migration-20261022/cadcraft` only. The shared +`storytold/egui` fork, `storytold/winit`, `storytold/craft-libs` and the sibling apps +are dependencies and hand-off points, not repositories changed by this plan. + +Claims are written to be checked against the named source. Historical compiler output +and measurements are clues, never current acceptance. + +Capture acceptance follows the suite contract: a queued-not-issued hidden request and +each settling frame must request `RequestPaintWhileHidden` from logic; only an issued +screenshot command forces paint itself. Callback arrival has no implicit wake, so the +live request owns the viewport waker. Record known issuance UI bounds and a geometry +epoch, reject/retry any intervening resize/DPI/zoom/surface change even if it changes +back, and derive pixel scale from returned dimensions only under an unchanged epoch. +Default limits are 4 requests, 128 MiB/image and 256 MiB aggregate; CADCraft's explicit +64 MiB limits remain stricter. Count reserved in-flight and completed images together. + + +## 1. Decision in one page + +CADCraft's committed dependency state does not currently build as written: +`Cargo.toml:52-57` asks for egui/eframe/egui-wgpu/egui_kittest 0.37 while `craft-ui` +is pinned to `5c874d51…`, whose egui dependency is 0.36.2. The lock therefore resolves +two incompatible egui families, and `DockArea::show` receives a 0.37 `Ui` from a +0.36-typed API. This is a reconciliation task, not a version bump. + +Target state: + +1. One immutable egui family from the **final reviewed `storytold/egui` revision**. + That revision is not known or pinned by this plan; it exists only after the approved + publication of the fork's reviewed patch series. The aiongg candidate + `a4f9a17f64a6bae61350fb85804c9df0d9865916` and upstream `841a328b` are comparison + inputs, never production pins. +2. One immutable winit from the **final reviewed `storytold/winit` revision** containing + the W1-W15 base, W17-W27 language/IME series and the new **W15P** PRIMARY path. The + aiongg candidate `8b35388bd719e8cc86a95971ba0bc026c9a8f058` contains W17-W27 but no + PRIMARY and is only a comparison input. +3. `craft-ui` and `craft-docpath` from one **final reviewed `storytold/craft-libs` + revision** that is current `main` (`49190884…`) plus only the egui-API hunks ported + from PR #199's `8614e4a…`. The PR head `b68de8c…` and audit tree `ec8840a…` are + stale unsafe trees and are never merged or checked out as the target. +4. CADCraft source changes limited to: the real compile-error classes + (`UserData`/`Event::Screenshot`, `MouseWheel.source`, private `FontsView.fonts`), the + deprecations `clippy -D warnings` rejects, panel-id/`PanelState` migration, the strict + kittest names/glyphs needed for a green first compatibility PR, and the deliberate + lifecycle/screenshot/PRIMARY/IME changes below. There is no `update`→`ui` rewrite: + `eframe::App::ui` and a root `Ui` are already used (`apps/cadcraft/src/main.rs:91`, + `crates/ui-egui/src/lib.rs:627`). +5. A lifecycle split that is correct under the **actual pinned integration**: both native + and web call `App::logic` and `App::ui` inside every `Context::run_ui` pass. A + completed `run_ui` leaves `current_pass_index() == 0`; a logic-only tick also reports + zero. Visibility is not a phase. Frame-once work uses the zero-pass-index guard; + input-dependent work moves into `App::ui`; consume-based queues run on every logic + callback. This is a correction of an existing 0.36/0.37 hazard, not a new 0.37 + lifecycle. +6. Screenshot completion uses the shared craft-ui CL-2 design: one bounded, cancelable + completion slot per admitted request, a callback holding only a **weak** reference to + that slot, capture-time viewport/geometry/DPI binding, and aggregate byte bounds. The + callback never holds a strong `Context` (which would create a Context→command→callback + cycle) and never writes files or encodes PNGs. +7. One atomic compatibility PR (S2), then behaviour PRs as in §12. S2 must already be + green under strict kittest accessibility and missing-glyph policy, so it owns the + accessible-name and `default_fonts`/`monochrome_emoji_fonts` work; S4 then owns the + broader language, AccessKit-language and Arabic checks. + +## 2. Baselines, exact revisions and evidence + +### 2.1 Checkouts and revisions + +| Role | Revision | Notes | +|---|---|---| +| Assigned CADCraft checkout | `1977a493c7d8340249185126744c5143f096dc86` | `HEAD`; branch `codex/egui-037-plan-20261022`; tracked tree clean; this plan is currently untracked | +| Last coherent 0.36.2 CADCraft | `a08fd7c1d1d7c16f40dc31c25f815370c136a1d4` | Parent of the Dependabot bump that moved direct UI deps to 0.37 | +| craft-libs `main` inspected | `49190884278f16c9a9f5823d7698a8ffb5deb062` | Baseline for the S1 rebase | +| craft-libs PR #199 API commit | `8614e4a04fae516fba16c22b31ff1008cb6be10b` | Source of the port hunks only | +| craft-libs PR #199 head | `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` | Stale tree; never a target | +| craft-libs audit merge | `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Stale audit tree; evidence only | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | Stock control and API source | +| aiongg egui candidate | `a4f9a17f64a6bae61350fb85804c9df0d9865916` | Comparison input; not a production pin | +| winit published base | `ffae5198770d7730e718043f035a772b7dbc3b1b` | `craft/0.30.13`, W1-W15 | +| aiongg winit candidate | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | W17-W27; no W15P; comparison input | +| Published egui 0.36.2 | `767b1cad294120689a514bb4bd68c077e46679b9` | Rollback patch inventory | + +**Final production pins are unresolved until approved publication.** The manifest edits +below use ``, `` +and ``. A candidate SHA may not be copied into a +production manifest, and a moving branch is never a pin. + +Governing instructions: repository `AGENTS.md`, suite `PLANNING-CONTRACT.md` and the +server instructions. `.agents/` and `.codex/` are empty; there is no nested `AGENTS.md`. +`AGENTS.md` (68 lines) and `CLAUDE.md` (49 lines) are **not** the same text: `CLAUDE.md` +lacks the chrono rule and the contributor-credit section. `AGENTS.md` governs. + +### 2.2 Dependency facts in the committed lockfile + +`Cargo.lock` currently contains: + +* `egui` 0.36.2 **and** 0.37.0; likewise `emath`, `ecolor`, `epaint`. +* `accesskit` 0.24.1 and 0.25.1 (one per egui family). +* `craft-ui` at `git+…?rev=5c874d51…#5c874d51…`, depending on `egui 0.36.2`. +* `craft-docpath` at `git+…?rev=82a24b0…#82a24b0…` (different craft-libs revision, + no egui dependency). +* `eframe`, `egui-wgpu`, `egui-winit`, `egui_kittest` at 0.37.0, and `winit` 0.30.13 + from crates.io. +* `arboard` 3.7.0 is present in the lockfile, but `cargo tree -i arboard --locked` + on the native graph prints nothing: the 0.37 `eframe` feature list + (`apps/cadcraft/Cargo.toml:19`) does not enable `eframe/clipboard`, which forwards + to `egui-winit/clipboard` (0.36's eframe enabled it unconditionally). **Without + adding `clipboard`, copy/paste silently stops reaching the OS clipboard**; + `crates/ui-egui/src/clipboard.rs:29-60` depends on `ctx.copy_text` and + `Event::Paste`. This is a verified graph fact, not a warning from the old audit. +* No root `[patch.crates-io]`, no `vendor/` and no **external/local path patch** in + this repository. Internal workspace members are path dependencies by design. +* Read-only `cargo tree -d --locked --offline` shows the 0.36 family reachable only + through `craft-ui`, and the 0.37 family through CADCraft/eframe/egui-wgpu/kittest. + This is graph evidence, not a build. +* The candidate fork's own `a4f9a17f/Cargo.toml:178-179` still has + `[patch.crates-io] winit = { …, branch = "craft/0.30.13-lang" }`. Consumers' patches + override it, but the fork's own build is not immutable. The final storytold fork/tag + must replace that branch with the reviewed rev before publication. + +### 2.3 Historical compiler clues (not acceptance) + +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/cadcraft.log` was +produced by a path-patched trial, not by the committed manifests. It reports: + +* 3 error classes (16 diagnostics in the `stock` run): `lib.rs:696` (`egui::UserData`), + `lib.rs:716` (`Event::Screenshot`), `animation.rs:78` (`MouseWheel` pattern lacks + `source`). The `stock` run additionally reports `Event::MouseWheel` initializers at + `animation.rs:145,197`, `canvas.rs:2806`, and private `FontsView.fonts` at + `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`. +* Warnings include 92 `egui::Id::new` calls (90 in `crates/ui-egui/src`, 2 in tests: + `tests/docking_workflows.rs:303`, `tests/options_dialog.rs:237`), 51 `ui.id()` calls, + 2 `WidgetType`, 1 deprecated `AsId`, and private-font/dependency warnings. +* The trial patched 11 crates.io crates and both craft-libs crates by path; those path + patches are evidence of the API delta and must never be committed. + +These are clues to re-check on the migration tip, not a fixed work list. + +## 3. Current implementation inventory + +### 3.1 Manifests and feature targets + +| File | Facts that matter here | +|---|---| +| `Cargo.toml:6-11` | workspace version 0.12.0, edition 2024, `rust-version = "1.90"` | +| `Cargo.toml:52-59` | `craft-ui` git rev `5c874d51…`; `egui`, `egui_kittest`, `eframe`, `egui-wgpu` set to `0.37`; unused `arboard` workspace entry | +| `crates/ui-egui/Cargo.toml:7` | UI crate already declares `rust-version = "1.95"` | +| `crates/ui-egui/Cargo.toml:20-32` | `egui`, `egui-wgpu`; dev `egui_kittest` with `wgpu,snapshot`; dev `naga 30` | +| `apps/cadcraft/Cargo.toml:19` | `eframe` features: `default_fonts,wgpu,accesskit,wayland,x11,links,persistence` — **missing `clipboard`** | +| `apps/cadcraft/Cargo.toml:29` | direct `winit 0.30` (x11) for the XWayland event-loop choice | +| `apps/cadcraft-web/Cargo.toml:29` | `eframe` web features: `persistence,default_fonts,wgpu,accesskit` | +| `apps/cadcraft-web/Cargo.toml:26-32` | `web-sys` for file input; `upload.rs` uses `DroppedFile::web_file` | +| `crates/render/Cargo.toml` | rayon only off wasm; not UI | +| `xtask/Cargo.toml` | no egui dependency | + +Platform cfgs that must keep compiling: + +| Cfgs | Where | What they gate | +|---|---|---| +| `cfg(target_arch = "wasm32")` | `ui-egui/lib.rs` (`now_ms`, `FrameCap`, autosave, dropped files), `ui-egui/theme.rs` (system-font probes), `render`, `dwg`, `apps/cadcraft-web` | no threads/system fonts/files on web; `now_ms` is 0 on wasm; `web_file` is wasm-only | +| `cfg(not(target_arch = "wasm32"))` | `apps/cadcraft` `rfd`, `sys-locale`; `ui-egui` autosave and frame cap | native-only dialogs/locale/autosave | +| `cfg(unix)` / `cfg(all(unix, not(macos)))` | `apps/cadcraft` `winit` x11, `zbus`; `desktop_theme.rs` | XWayland choice, portal theme | +| `cfg(target_os = "macos")` | `muda`, `objc2-app-kit`, `native_menu.rs`, `system_animations.rs`, integrated titlebar | native menu, reduce-motion, traffic-light layout | +| `cfg(target_os = "windows")` | `hidapi` windows-native, `windows` `UI_ViewManagement`, `graphics.rs`, `windows_subsystem` | DX12/FXC startup, animation settings | +| `cfg(target_os = "linux")` | `hidapi` linux-native-basic-udev | spacemouse HID | + +`crates/fonts/build.rs` (`CRAFT_FONTS_DIR`) and `crates/ui-egui/build.rs` +(`contributors/*`) are build inputs unrelated to egui; do not change their contracts. + +Packages/binaries: `cadcraft` (desktop, eframe), `cadcraft-web` (wasm, eframe web +runner), `cadcraft-cli` (headless, no egui), `cadcraft-ui-egui` (library, kittest), +`xtask` (layers/wasm/parity). There are no examples besides `examples/apartment.dxf` +(48,769 bytes, tracked), no benches, no committed screenshot fixtures. + +### 3.2 Entry points + +* Native: `apps/cadcraft/src/main.rs:228` `main`; `NativeOptions` at `:262-283`; + Windows DX12/FXC at `:285` via `graphics::configure`; wgpu buffer-limit workaround + at `:289-297`; XWayland event loop at `:301-306`; `run_native` at `:307`; + `set_wgpu(rs, depth_format_from_bits(0, 8))` at `:347`. +* `eframe::App` at `apps/cadcraft/src/main.rs:67-110`: `logic`, `raw_input_hook`, + `ui`, `on_exit`, `save`, `persist_egui_memory = false`. +* Web: `apps/cadcraft-web/src/main.rs:31-58` `Shell` (same `logic`/`ui` split); + `WebRunner::start` at `:114`; `?cpu`/`?webgl` at `:108-113`; + `CadApp::set_wgpu(rs, None)` for web (no stencil). +* Headless: `cadcraft-cli` and `cadcraft-mcp` never construct egui; `ui.render` + is the CPU raster path in `crates/ui-egui/src/control.rs:470-545`. +* Control channel: `apps/cadcraft/src/control_server.rs`; methods in + `crates/ui-egui/src/control.rs:195-215` and `docs/control-protocol.md`, enforced + by `tests/control_methods.rs`. + +### 3.3 Actual frame lifecycle and the pinned integration + +Verified against native `crates/eframe/src/native/epi_integration.rs:338-365` and +`native/run.rs:235-281`, web `crates/eframe/src/web/app_runner.rs:280-325`, and egui +`context.rs:1099-1158,1190-1218,2124-2129`: + +* **Both native and web call `App::logic` and `App::ui` inside the `run_ui` closure, + once per pass.** Native `update` calls `app.logic(ui.ctx(), frame)` then `app.ui(ui, + frame)`; web does the same. On a second discard pass the same two calls happen again + with empty input. This is already true on 0.36.2 (`767b1cad` has the same order); it + is not a 0.37 introduction. +* A hidden root window runs `ctx.run_logic` → `App::logic` only. Native reaches this + through `PassMode::LogicOnly` when a redraw is overdue (`native/run.rs:267-281`); + web through `run_logic` when the tab viewport is not visible (`app_runner.rs:318-328`). + No egui pass runs, so `current_pass_index()` stays 0 and input/layout remain from the + last pass. +* `current_pass_index()` is `0` during pass 1 and during a logic-only call, `1+` during + later passes. After a completed `run_ui`, `end_pass` takes the viewport output + (`mem::take`), so it is **0** again; do not expect a nonzero index after completion. +* `ViewportInfo::visible()` is derived from `minimized`/`occluded` + (`egui/src/data/input/viewport_info.rs:98-104`); it is `None` when either flag is + unknown and `Some(true)` only when both say visible. A compositor can withhold a + frame without setting either flag, so a logic-only overdue tick can still see + `None` or `Some(true)`, and a hidden window can still run a full `run_ui` + when a screenshot or `RequestPaintWhileHidden` asks for pixels + (`native/wgpu_integration.rs:695-727`). **Visibility cannot distinguish a logic-only + tick from a forced hidden UI pass.** +* `App::raw_input_hook` is called by `prepare_raw_input` for both `update` and + `update_logic_only` (`epi_integration.rs:338-344,385-425`) and before the visibility + branch on web (`app_runner.rs:284`). It must not assume that a UI pass follows. +* `Context::run_ui` re-runs its closure for each pass requested by + `Context::request_discard` (default `max_passes = 2`). `Context::root_ui` panics while + the root `Ui` is borrowed (#8535); CADCraft keeps the handed-in `&mut Ui`. +* Screenshots are callback based: `ViewportCommand::Screenshot(ScreenshotCallback)` / + `Context::request_screenshot` (#8489); the callback receives only + `Arc`, runs at most once, and does **not** request a repaint. +* #8748 is a **closed, unmerged** PR (“run the ui of an overdue window without + painting”) and is not in the pinned 0.37 source. The pinned source has + `PassMode::LogicOnly`, not `PassMode::Overdue`; its body confirms that such a window + “only runs `App::logic`”. Do not use #8748 to justify current behavior. + +### 3.4 UI state, commands, undo and persistence + +* UI state is `UiState` (`lib.rs:70-112`) and is persisted by the desktop + `apps/cadcraft/src/workspace.rs` as JSON (`docking`, panel visibility, theme, language, + recent files), version 1, bounded at 64 KiB. +* `persist_egui_memory` is false (`apps/cadcraft/src/main.rs:107`), so eframe does not + save egui `Memory`. The app's own `Workspace` JSON does not store egui ids or + `PanelState`. +* All user-visible edits go through `CadApp::run` / `start` / `cmdline` + (`lib.rs:315-359`) or menus; engine undo is the single transaction owner. +* Docking is `craft_ui::docking::Layout` (`docking.rs:1-130`), serialized + without egui ids; the surface is created with `egui::Id::new("cadcraft-docking")` + and `cancel_drag` with the same id (`docking.rs:339,375`). +* Top-level panels are `exact_size(...)` and non-resizable: `chrome.rs:69,180,300`, + `menus.rs:573`, `palettes.rs:330,346`. + +### 3.5 GPU, control and screenshots + +* `crates/ui-egui/src/gpu.rs:681` `install` registers error/device-lost handlers and + inserts `Resources` into `egui_wgpu::Renderer::callback_resources`. +* `impl egui_wgpu::CallbackTrait for CanvasCallback` (`gpu.rs:693-780`) uses + `ScreenDescriptor`/`PaintCallbackInfo` fields unchanged in 0.37. +* `main.rs:188` asks for an 8-bit stencil buffer; clipped paper-space viewports are + drawn through `Stencil::Mask`/`Stencil::Inside`. +* `ui.screenshot` currently queues `(token, deadline, frames)`, then requests a + viewport screenshot and matches `Event::Screenshot` by token (`lib.rs:688-740`); + replies are correlated by `shot_token` only. `save_screenshot` + (`control.rs:547-559`) writes RGBA PNG. +* `ui.render`, `ui.inspect` and `ui.elements` are CPU/state paths and do not depend + on a presented frame. `ui.inspect` currently exposes `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`, `building`, `renderer` (`control.rs:270`) but **not** + `cumulative_frame_nr`, pass index or `cpu_usage`. +* There is no `view.set` control method. View changes use `cmdline.input` (ZOOM/PAN), + `engine.execute`, `ui.pointer`, `ui.click`/`ui.move`/`ui.drag`, or `ui.set` for + `UiState` fields. + +### 3.6 Text, fonts, language and accessibility + +* `theme::install_fonts` (`theme.rs:314-387`) starts from + `egui::FontDefinitions::default()`, prepends an installed system UI face, appends + CJK/Japanese/Arabic/Hangul faces and embedded craft-fonts faces. Tests inspect the + installed character maps through `ctx.fonts_mut(|view| view.fonts.font(&family))` + (`i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`). In 0.37 + `FontsView.fonts` is private; use `view.characters(&family)`/`view.has_glyph`. +* The interface catalog has **16** languages (`i18n/mod.rs:173`), including Arabic + (`ar.tsv` 1235 lines). `theme::can_show` (`theme.rs:640-647`) gates Arabic, Chinese + and Korean on an installed or embedded face. Bundled epaint fonts cover Latin/Greek/ + Cyrillic but not Arabic, CJK or Hangul. +* Shortcut labels and catalogs contain `⌘` U+2318, `⇧` U+21E7, `↩` U+21A9, `−` + U+2212, `…` and `°`. In 0.37 `⌘` is only in `emoji-icon-font`, included only with + `monochrome_emoji_fonts`; `⇧` is only in Hack (Monospace). U+22EE is absent from both + 0.36.2 and 0.37 bundled fonts, so its absence is not a 0.37 change. +* Accessibility metadata is built with `egui::WidgetInfo` in `icons.rs:965-989`, + `craft_ui` buttons, `egui::accesskit_node_builder` for tab roles, and control-channel + role strings (`control.rs:113-180`). There is no explicit UI language in AccessKit. +* There are 27 `TextEdit`, 18 `DragValue`, 32 `ComboBox` and 8 `Slider` sites. CADCraft + currently has no password field and no custom `IMEOutput` producer: its editors use + the built-in `egui::TextEdit` (27 sites), including the MTEXT editor and dialog/layer + rename fields. + +### 3.7 Tests, gates and CI + +* `cargo xtask ci` = fmt, clippy `-D warnings`, workspace tests, assets, layers, wasm + (`xtask/src/main.rs:151-205`). The wasm step covers L0-L4 plus `ui-egui`, **not** + `cadcraft-web`. +* `apps/cadcraft` and `apps/cadcraft-web` are app packages; the web runner needs its + own `cargo check --target wasm32-unknown-unknown` and `trunk build --release`. +* `crates/ui-egui/tests` has **43** `*.rs` files. The affected inventory includes + `alias_dialog.rs` (an unnamed filter `TextEdit`, §8.3), `animation_effects.rs` + (two more `MouseWheel` constructors), `chrome_geometry.rs` (status text at **1200×760 + and 900×560**, `tests/chrome_geometry.rs:58`), `docking_workflows.rs`, + `workspace_focus.rs`, `hidden_command_line.rs`, `pickdrag.rs`, + `gpu_viewport_stencil.rs`, `gpu_faults.rs`, `shared_controls.rs`, + `control_methods.rs`, `menubar_hover.rs`, `context_menu.rs`/`context_menus.rs`, + `style_dialogs.rs`, `dialog_dispatch.rs`, `text_double_click.rs`, `save_version.rs`, + `rapid_clicks.rs`, `drawing_recovery.rs`, `options_dialog.rs`, `options_followups.rs`, + and `src/i18n/tests.rs`. There are no golden images; the only PNG output is opt-in + (`CRAFT_UI_SUITE_SHOTS`, `CRAFT_UI_VISUAL_DIR`). +* There are ~68 direct `CadApp::logic` call sites, many in tests. After the split, a + direct call on a bare `Context` still reports pass index 0, so it keeps running + frame work; tests that need multi-pass semantics must use the `run_ui` helper shape + used by `hidden_command_line.rs:19-30`. + +## 4. Upstream 0.37 compile incompatibilities + +These remain **after** `craft-ui` is moved to the same egui 0.37 source; before that, +the craft-ui type mismatches dominate. Re-count on the migration tip. + +| # | Anchor | Symptom | Replacement and semantics | +|---|---|---|---| +| C1 | `lib.rs:688-706` (`issue_screenshots`), `:707-740` (`collect_screenshots`) | `egui::UserData` gone; `Event::Screenshot` gone | Use `ctx.request_screenshot(move |image: Arc| …)` (or `ViewportCommand::Screenshot(ScreenshotCallback::new(...))`). Keep the token, path, reply and deadline in CADCraft; the callback hands `(token, image)` to the request's completion slot. Call `ctx.request_repaint()` from the frame tick after completion, not from a callback holding a strong `Context` (§6.5). | +| C2 | `animation.rs:78` pattern; `animation.rs:145,197`, `canvas.rs:2806`, **and tests `animation_effects.rs:102,133`** initializers | `MouseWheel` gained `source: MouseWheelSource` | Add `source: _` to the pattern (or `..`); construct test events with `MouseWheelSource::Unknown` (the platform value on X11/Wayland/Windows/web) or `Wheel`/`Trackpad` where the test is about macOS momentum. Prefer a local test helper so a future field does not break every constructor. | +| C3 | `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168` | `FontsView.fonts` is private | Replace `view.fonts.font(&family).characters()` with the public `view.characters(&family)` or `view.has_glyph(&FontId, c)`. Keep the test intent: the family's actual character map, not a fallback glyph. | +| C4 | `icons.rs:965-989` (`describe_control`), all `WidgetType::Button` | `WidgetType` is a deprecated alias of `accesskit::Role` | Use `egui::Role::Button`; `craft-ui` uses `Role::CheckBox`. Assert the role in a semantic test. | +| C5 | 92 `egui::Id::new(...)` calls (90 src, 2 tests: `docking_workflows.rs:303`, `options_dialog.rs:237`), e.g. `palettes.rs:615,891,957`, `exchange.rs:75-144`, `docking.rs:339,375` | Deprecated; `clippy -D warnings` fails | `egui::Id::unique(...)` (same hash; `Id::new` now calls `Id::unique`), or `ui.make_persistent_id(salt)`/`IdSalt::new` where a scope-local id is wanted. `docking.rs` must keep the exact same id string for `DockArea` and `cancel_drag`. | +| C6 | 51 `ui.id()` calls (e.g. `chrome.rs:83`, `palettes.rs:154`, `cmdline.rs:446`) | Deprecated | `ui.scope_id()`. No value change. | +| C7 | `managers.rs:77` `num_field(ui, id: impl egui::AsId, …)` | `AsId` is a **deprecated blanket trait**, not removed (`egui/src/id.rs:7-12`, exported at `lib.rs:550`); clippy `-D warnings` rejects its use | Take `impl egui::AsIdSalt` and use `ui.make_persistent_id(id)`; the temporary field id becomes scope-local. | +| C8 | `egui::Panel::top/left/right/bottom("…")` (`chrome.rs:69,180,300`, `palettes.rs:330,346`, `menus.rs:573`) | 0.37 panel constructors take `impl AsIdSalt`; `From<&str> for Id` is gone; the panel id becomes `parent_ui.scope_id().with_salt(...)` (`panel.rs:974-976`) instead of 0.36's `Id::new("…")` | Keep the literal names `cc_title_toolbar`, `cc_menubar`, `cc_status`, `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`; do not hard-code ids in tests. The first compatibility PR includes the additive `PanelState` key migration in §7. | +| C9 | `UiBuilder::id` inside `craft-ui` | Deprecated | `scope_id`; handled in craft-libs S1, not here. | + +No other 0.37 removals appear in CADCraft source: no `AsId` removal, no +`WidgetType::TextEdit`, no `global_theme_preference_switch`, no `show_inside`, +no `ImeEvent` matching, no `ScrollArea::id_source`. + +## 5. Runtime and semantic changes CADCraft must decide + +| Area | 0.37 change (primary source) | CADCraft decision | +|---|---|---| +| Layout/frame | `Context::run_ui` multi-pass; **native and web call `App::logic` per pass**; hidden root runs `run_logic` only; completed `run_ui` reports pass index 0 | Use `current_pass_index() == 0` for frame-once work; move input to `App::ui`; never use `visible()` as a phase (§6). | +| Screenshot | callback-based (#8489) | Bounded, cancelable per-request completion slot; weak callback; capture-time binding; deadline; one file/reply (§6.5). | +| Clipboard | `eframe` gains optional `clipboard` (#8392) | Add `"clipboard"` to the desktop `eframe` features. On web, use the browser path; verify paste/copy in a real browser. | +| Fonts | `default_fonts` no longer default (#8583); eframe `system_fonts` default (#8512) | Enable `default_fonts` and `monochrome_emoji_fonts` in **S2**; keep `system_fonts` off and set `NativeOptions::system_font_fallback = false`. | +| Glyphs | kittest panics on a missing glyph by default (#8601); U+22EE absent from both 0.36.2 and 0.37 bundled fonts | Enable the monochrome emoji face; run a strict glyph sweep in **S2**; replace uncovered UI symbols with code-drawn art/text; never set a global `MissingGlyphPolicy::Tofu` for the suite. | +| TextEdit | clicking another widget collapses the selection before the button runs (#8763; unmerged #8764) | Assert the **exact selected range** is formatted by the MTEXT Bold/Italic/Underline/Overline toolbar and any dialog action; pointer and keyboard paths. CADCraft's MTEXT toolbar is drawn before the `TextEdit` and uses its own `st.sel`, which may make it immune; prove it, do not assume it. If a regression is real, preserve the range in CadCraft or adopt the fork fix; do not patch by disabling selection. | +| Menus | arrow-key navigation within open menus (#8548), popup sizing passes | Re-run `menubar_hover`/`context_menu(s)`; add keyboard traversal. | +| Accessibility | `WidgetType`→`Role`, unnamed inputs fail kittest (#8555/#8589/#8611), `label_inputs_by`/`name_inputs`/`accessible_language` | Name every input reached by tests in S2; add the systematic unnamed-widget audit; put the UI language on the AccessKit root via fork E17 in S4. | +| Clipboard/PRIMARY | 0.37 supports X11/Wayland PRIMARY (#8428); Wayland clipboard #8657/E4; **W15P is new** | Require winit W15P + egui E4/P2b in the final pins; test the native matrix; do not default to a PRIMARY-only fallback (§9). | +| Web | `DroppedFile::web_file` opt-in (#8669) | eframe enables it on wasm; assert the feature is in the **wasm** graph and add `cargo check -p cadcraft-web`. | +| Scroll/animations | kinetic scrolling (#8662/#8663), repaint-burst fix (#8595), style sizing (#8617/#8445) | Re-run animation on/off tests; apply wheel/zoom once and reuse the resulting view for later passes (§6); measure idle frames; keep explicit spacing overrides. | +| Hidden window | pinned eframe runs `PassMode::LogicOnly` for an overdue root; **#8748 is closed and unmerged**; no deferred viewports in CADCraft | Keep control/LISP/autosave/screenshot deadlines alive on logic ticks; handle input only in a known UI pass. Do not claim #8748 behavior. A fork that later adopts an overdue full pass must pin that exact revision and re-run the hidden tests. | + +## 6. Root `Ui`, multi-pass and logic-only semantics + +### 6.1 Rules + +1. `CadApp::ui(&mut self, ui: &mut egui::Ui)` remains the only root-`Ui` entry. + Panels are added in the existing order; dialogs/windows/areas continue to use + `ui.ctx()`. No code calls `Context::root_ui` inside `ui` (it would panic, #8535). +2. `frame_tick(ctx) := ctx.current_pass_index() == 0`. It is true on pass 1 and on + every logic-only tick, and false on later passes. It is the only frame-once guard. + Do **not** use `cumulative_frame_nr` for this: `run_logic` does not increment it, so + a hidden tick and the next full frame share a number and would duplicate work. +3. Input-dependent work (shortcuts, clipboard mirror, dropped files, canvas zoom + from wheel events) runs in `App::ui` on the first pass, never in `App::logic`. + `run_logic` leaves events/time stale; reading `dropped_files` or `Event::Paste` + there replays a user action on every hidden tick. +4. Consume-based background work runs on **every** `App::logic` call: the control + mpsc drain, opening-job receiver, screenshot completion receiver, GPU-fault + `take`, and the Plot/Hatch resume flags. These cannot duplicate because each item + is consumed once. They must not be hidden behind the pass-index guard, or a fresh + control reply, opening result or GPU fault arriving during a later pass would + starve until the next frame. +5. Deadline/time-based work (LISP slice, autosave, queued-screenshot issue, workspace + persistence, spacemouse poll, native-menu poll) runs on `frame_tick`. Autosave and + the screenshot queue are additionally idempotent by their own deadlines; the guard + keeps pacing deterministic rather than relying on that. +6. UI is never shown from `logic`. The opening spinner currently drawn at + `lib.rs:580` moves into `draw()` as a foreground `Area`; `opening::show` keeps its + rendering code but is called from `ui`. +7. Per-pass handlers that do run twice must be idempotent by a saved value, a + `shown_*` field or a consumed queue. `control::begin_frame` still clears the + observed-widget list at the start of each UI pass. +8. Input events are removed only after the app handled them: `raw.dropped_files` + always; `Copy`/`Cut`/`Paste`/`MiddleClickPaste` only when + `!ctx.egui_wants_keyboard_input()` (the guard `menus::shortcuts` uses). A focused + `TextEdit` still receives them. Do not clear `Text`/`Key` before `CadApp::ui` + because the command line reads them there. + +### 6.2 Split (design, not code) + +```rust +fn frame_tick(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 // pass 1 or a logic-only tick; 0 after completion +} + +pub fn logic(&mut self, ctx: &egui::Context) { + // Consume-based: every call, never behind the guard. + self.drain_control(ctx); + self.finish_opening(); + self.collect_screenshots(ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + + if frame_tick(ctx) { + // Once per layout frame or hidden tick. + self.session.lisp_tick(); + if self.session.lisp_busy() { ctx.request_repaint(); } + self.close_request_frame(ctx); + self.apply_theme_fonts_zoom(ctx); + self.issue_screenshots(ctx); + self.autosave_tick(ctx); + if !self.synthetic.is_empty() { ctx.request_repaint(); } + } +} + +pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx(); + if frame_tick(ctx) { + self.ui_input(ctx); // shortcuts, clipboard mirror, dropped files + self.frame_wheel(ctx); // apply fresh wheel/zoom exactly once + self.frame_cap_wait(); // pacing belongs to a painted pass + } // later passes draw the already-updated state; never re-inject consumed wheel input + i18n::with_language(self.language(), || self.draw(ui)); +} +``` + +`ui_input` clears `raw.dropped_files` after opening them, and calls +`menus::shortcuts` (which consumes shortcuts and clipboard events). `frame_wheel` +runs `animation::unsmooth_scroll` when animations are off and applies the frame's +wheel scroll/zoom once. Later passes render the resulting view; they neither re-inject +events nor reapply a cached delta in `canvas.rs`. `frame_cap_wait` is moved out of `raw_input_hook` because the hook also +runs on logic-only ticks where no paint will happen. + +`raw_input_hook` keeps only work that is safe without a pass: the desktop theme +override, `cmdline::capture_tab`, and synthetic-event injection. Injection drains +`self.synthetic` once into `RawInput`; eframe preserves that input in +`pending_raw_input` across logic-only ticks, so repeated ticks do not duplicate the +same event. Add a test that a queued synthetic event is applied once after a hidden +tick followed by a shown frame. + +### 6.3 Side-effect placement + +| Work | Anchor | Class | Why / test | +|---|---|---|---| +| LISP slice and repaint while busy | `lib.rs:538-541` | frame tick | One slice per layout frame; a hidden window keeps it alive. Test a LISP routine and a two-pass frame plus a minimized tick. | +| Close-request / CancelClose | `lib.rs:542-545` | frame tick | `run_logic` updates viewport close/focus; cancel must work while hidden. | +| Font install, theme/animation/zoom | `lib.rs:546-577` | frame tick (guarded `shown_*`) | Applied before pass 1; repeated calls are no-ops. Tests call `logic` repeatedly and expect a theme change. | +| Control queue | `lib.rs:664-687` | every call (consume) | Requests/replies must not duplicate; each mpsc item is handled once. Test one command in a two-pass frame mutates once, and a request queued between passes is answered without waiting for the next frame. | +| Opening jobs + spinner | `lib.rs:579-580`, `opening.rs:77-97` | receiver every call; spinner in `ui` | Worker results are consumed once; drawing is UI. Test the spinner shape appears in a `run_ui` output and no `Area` is created from `logic`. | +| Synthetic repaint | `lib.rs:581-583` | frame tick | Synthetic events are drained in the hook; request a repaint while they remain. | +| Screenshot collection/issue | `lib.rs:584-585`, `:688-740` | collection every call; issue frame tick | Correlation/deadline survive multi-pass; one request → one callback → one reply. | +| GPU faults | `lib.rs:586`, `autosave.rs:94-103` | every call (`take`) | Device errors arrive between frames; `save_now` runs once per fault. | +| Plot/Hatch resumes | `lib.rs:587-588` | every call (flags/take) | State-machine continuation; a second pass must not re-open a dialog twice. | +| Autosave | `lib.rs:589`, `autosave.rs:85-90` | frame tick | `Autosaver::tick` is time-based; after scheduling a write it records `written`, so it is idempotent, but one tick per frame keeps pacing deterministic. (SAVETIME 0 disables autosave; it does not write twice.) | +| Shortcuts + clipboard mirror | `lib.rs:590`, `menus.rs:629-700`, `clipboard.rs:29-60` | `ui` first pass | Input-derived; a hidden `run_logic` must not replay stale `Event::Paste` or copy twice. | +| Dropped files | `lib.rs:591-597` | `ui` first pass, clear after | **Current hazard**: stale `dropped_files` would re-open on every hidden tick and twice in a two-pass frame. Test one drop, then two hidden ticks. | +| Wheel/scroll/zoom input | `lib.rs:568-570`, `animation.rs:104-130`, `canvas.rs:1334,1396` | `ui` first pass only | Apply the input once; later passes draw the updated view/ScrollArea state without re-injection. Assert exactly one notch equals one view/scroll step under forced two-pass layout, animations on/off. | +| Spacemouse poll (host) | `main.rs:80`, `spacemouse.rs:61-80` | frame tick | `poll` copies `shared.motion` and re-applies it per call; one poll per frame. Test with a counter. | +| Native menu poll (macOS) | `main.rs:74-76` | frame tick | Rebuilds enablement a few times per second; not per pass. | +| Workspace persistence (host) | `main.rs:81,93` | frame tick (logic only) | `save_if_changed` is idempotent but must not run per pass; `save_on_exit` stays in `on_exit`. Remove the duplicate call from `App::ui`. | +| Web inbox drain / dirty bridge | `apps/cadcraft-web/src/main.rs:33-44` | drain every call; dropped files in `ui`; dirty report frame tick | DOM reporting is idempotent; dropped files are input and must be read once. | +| `raw_input_hook` frame cap/synthetics | `lib.rs:601-625` | per logic callback | Runs in `update` and `update_logic_only`; move the frame-cap wait to `ui` pass 1; synthetic injection is drain-based and preserved in pending input. | + +### 6.3.1 Host and web frame token + +The host `eframe::App::logic` implementations are outside `CadApp`, so they use the +same public token rather than a new scheduler: + +* native `apps/cadcraft/src/main.rs:68-84`: call `self.cad.logic(ctx)` on every + `App::logic` invocation; wrap `native_menu.poll`, `spacemouse.poll`, + `persist_workspace(false)` and the `quit_requested` check in + `if ctx.current_pass_index() == 0`. Remove `persist_workspace` from `App::ui` + (`main.rs:91-94`), which currently runs it a second time. `spacemouse.poll` copies + `shared.motion` rather than taking it (`spacemouse.rs:61-80`), so without the guard a + second pass applies the same motion twice. +* web `apps/cadcraft-web/src/main.rs:31-44`: `Inbox::drain` (background results) runs + on every logic call; `Bridge::report_dirty` runs on `frame_tick`; `take_dropped` + moves to `Shell::ui` and clears `raw.dropped_files` after reading them. Remove the + duplicate `report_dirty` from `Shell::ui`. `Inbox::take_dropped` currently clones + `raw.dropped_files` without consuming them (`upload.rs:71-77`), so it would read the + same files twice in a two-pass frame and on every hidden tab tick. +* `raw_input_hook` remains per logic callback on both hosts (see §3.3); only the frame + cap moves to the first painted pass. + +Add host/web counters in S3 tests: one spacemouse poll per frame, one native-menu +poll per frame, one dirty report per frame, and one dropped-file read per event. + +### 6.4 Screenshot callback correlation and bounds + +State per admitted request (owned by `CadApp`, with paths/replies/deadlines kept in +the app): + +``` +ShotRequest { + token: u64, path: Option, reply: Sender, deadline: f64, + viewport: ViewportId, session_generation: u64, + expected_px: (u32, u32), expected_ppp: f32, + slot: Arc, // one completion slot per request +} +ShotSlot { state: Mutex, wake: egui::Context } +ShotState { canceled: bool, result: Option, String>>, bytes: usize } +``` + +`issue_screenshots` waits for `canvas.building()` and at least three accepted UI frames (an intentional settling unit, never three discarded passes), then +calls `ctx.request_screenshot` with a closure holding **`Weak` only**: + +```rust +let slot = Arc::downgrade(&request.slot); +ctx.request_screenshot(move |image: Arc| { + if let Some(slot) = slot.upgrade() { + let wake = slot.complete(image); // stores image/result, releases the lock + if let Some(ctx) = wake { ctx.request_repaint(); } // Context lives in the slot + } +}); +``` + +The callback must not capture a `Context` strongly: the callback is itself stored in +that context's viewport commands, so a strong `Context` would form a cycle. It must +not encode PNGs, write files, run commands, or hold a lock while waking. + +`collect_screenshots` drains the slot results on every logic call, checks the token +and request/session binding, and replies with `save_screenshot(&image, path)`. Binding: + +* CADCraft has ROOT only: bind to an explicit CadApp session/document generation. + The callback supplies pixels only; viewport identity is request-owned, not callback + metadata. Reject generation changes/close and never reuse an old result for a new document; +* At issuance refresh expected geometry from the target UI content rect and ppp, + after the settling wait. If no fresh UI geometry exists, request a paint before issue. + Revalidate a monotonic resize/DPI/zoom/surface epoch before saving; derive scale from + returned pixels and unchanged logical bounds, including E2 fitting. A mismatch is: + reply `{"ok":false,"error":"window geometry changed during capture"}` and release + the image rather than silently pairing it with a newer request; +* a late callback whose slot is canceled or dropped releases the image without a + file or reply; a timed-out request leaves no file and cannot later write one; +* admission limits: at most 4 pending/queued requests and at most 64 MiB aggregate + reserved in-flight plus completed image bytes (64 MiB per-image maximum as well). + Reserve checked expected width×height×4 bytes at admission, adjust before issue after + a geometry change only if capacity permits, otherwise reject. Requests over the + limit are rejected immediately with an error reply. `pending_bytes` is decremented + on drain, timeout, cancellation and dropped slots; +* cancel all slots on close/viewport loss and in `on_exit`; cancellation is a flag, + not a process kill. `now_ms` is 0 on wasm, so the deadline uses + `web_time::Instant` on web, never stale InputState::time; do not leave an infinite + web deadline. + +CADCraft adopts the shared craft-ui CL-2 completion-slot API (the eleven existing craft-ui creative consumers' +approach; DeckCraft, FigCraft and ArtCraft Native keep local adapters). +Request records, paths, PNG serialization, timeouts and the reply schema stay in +CADCraft. If the provisional CL-2 API is unavailable, S3 is blocked rather than +growing a second divergent helper. + +Regression assertions: + +* two requests in one frame, distinct paths, both files have the harness's pixel size + and each reply names its own path; +* a request while a popup forces a second pass produces exactly one file; +* a timed-out request leaves no file and cannot later write one; +* a minimized/hidden window reports the documented timeout, or succeeds through a + forced hidden paint (`RequestPaintWhileHidden`/screenshot), never hangs; +* admission overflow returns an error and does not grow memory; +* a completion after resize/DPI change returns the geometry-mismatch error; +* a late callback after close writes nothing. + +kittest fulfils `ViewportCommand::Screenshot` in `Harness::handle_screenshots` +(0.37 `egui_kittest/src/lib.rs:909-947`), so routing/bounds are testable without a +real window. + +### 6.5 Multi-pass and logic-only tests + +* `two_pass_frame_runs_frame_logic_once`: force a discard pass (open a ComboBox or + menu); assert one non-idempotent counter/LISP slice and one control execution. + Mutation: move the LISP slice into an unguarded call; the test must fail. +* `logic_tick_with_unknown_visibility`: call `run_logic` with + `minimized=None, occluded=None`; queue a control `ping`, a screenshot deadline and + one LISP slice; assert all progress and no UI pass. +* `logic_tick_with_visible_true`: same with `minimized=Some(false), occluded=None`; + this is the case the old `visible()` predicate got wrong. +* `hidden_painted_two_pass_frame`: with `minimized=Some(true)`, run a full `run_ui` + with a forced second pass; assert frame work once, input once, and the final pass + renders the once-updated view without applying wheel movement again. +* `hidden_logic_ticks_do_not_reopen_dropped_files`: one shown frame with a dropped + file, then two `run_logic` ticks; the file opens once. Mutation: move drop handling + into `logic`; the test must fail. +* `hidden_logic_ticks_do_not_paste_twice`: one `Event::Paste`, a shown frame, then + two `run_logic` ticks; the paste command runs once. +* `fresh_control_between_passes`: request a discard in pass 1, enqueue a control + request before pass 2; it is answered in pass 2, not starved. +* `control_queue_answers_while_minimized`: `ping`/`ui.inspect` replies arrive on + `run_logic` ticks and no UI pass occurs. +* `opening_spinner_is_drawn_in_ui`: the spinner `Shape` appears in a `run_ui` output; + no `Area` is created from `logic`. +* `spacemouse_one_poll_per_frame` and `web_drop_once`: counters across a multi-pass + frame and a hidden tick. +* `hidden_child_only_if_adopted`: CADCraft has no deferred viewports, so assert that + (no `show_viewport_deferred` call) and record #8748 as not applicable. If a later + fork pin adopts an overdue full pass, add a hidden-parent/deferred-child + open/update/close test with overdue redraw and unknown visibility before claiming it. +* `undo_and_save_reopen_still_single_step`: command → undo → redo → save → reopen via + control methods; assert entity counts and DXF bytes, not just `ok`. +* `direct_logic_tests_do_not_silently_skip_frame_work`: audit the ~68 direct + `CadApp::logic` call sites; tests whose intent is a frame use the `run_ui` helper. + +## 7. Panels, docking, IDs and persistence + +* Keep the panel order from §3.3. The root `Ui` is full-window; panels consume space + before `CentralPanel`, so a panel id or separator/clip change moves the canvas rect. + Assert `canvasRect` equals the window minus the top/bottom/left chrome at + **1600×1000** (new test) and keep the existing status-bar geometry at + **1200×760 and 900×560** (`chrome_geometry.rs:58`). +* Keep the panel id strings `cc_title_toolbar`, `cc_menubar`, `cc_status`, + `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`. Do not rename them during the + `AsIdSalt` move; tests and `ui.elements` refer to behaviour around them. +* **Panel `PanelState` migration (first compatibility PR).** 0.36 keyed a panel's + `PanelState` by `Id::new("cc_x")`; 0.37 keys it by + `parent_ui.scope_id().with_salt(IdSalt::new("cc_x"))` (`panel.rs:974-976`). CADCraft + currently sets `persist_egui_memory=false` and its top-level panels are + `exact_size(...)`/non-resizable, so no `PanelState` is currently file-persisted. The + first compatibility PR nevertheless owns the saved-key migration and performs it + additively before the first UI pass: if the new key has no `egui::PanelState` and + the old `Id::unique("cc_x")` key does, + copy the old value to the new key; keep the old key for rollback. Seed a + `Context` with an old-key `PanelState` in a test and assert the new key sees it. + This prevents a one-time width reset if eframe memory persistence is enabled or a + panel becomes resizable, and covers saved test fixtures/other consumers. +* `DockArea::new(egui::Id::new("cadcraft-docking"))` and + `cancel_drag::(ctx, egui::Id::new("cadcraft-docking"))` must migrate together + to `Id::unique` with the same string. A mismatch leaves a drag active after a + workspace replacement; `docking_workflows.rs` already tests host cancel. +* `DockArea::direction` is an **explicit opt-in visual policy** from craft-libs + §7.7/CL-3. CADCraft passes `DockDirection::LeftToRight` by default and + `RightToLeft` only in S5b if chrome mirroring is implemented. Never reverse saved + tab order to simulate RTL. Preserve serialized split first/second, axes, ratios, + floating rectangles and logical tab order; painting, hit tests, drag insertion, + accessibility order and trailing resize grips must use the same computed + rectangles; physical splitter direction is unchanged. Test LTR/RTL click, drag, + reorder, resize, keyboard focus, protected floating groups and an unchanged + serialized workspace; deliberately mirror paint without hit coordinates to prove + detection. +* Docking layout persistence is `Workspace` JSON (`docking.rs:74-130`), not egui ids. + Assert `ui.dock`, `ui.set`, save/reload with floating groups, stacks and hidden + panels after the craft-ui update; changing direction must not rewrite the JSON. +* Narrow-window behavior remains: toolbar overflow (`chrome.rs:96-120`) keeps every + command reachable; `chrome_geometry.rs` must keep passing at 1×/2×. +* There are no secondary native viewports (`show_viewport_deferred`/immediate viewport + use), so fork E1 (`ViewportHooks`) is unused. “Viewport” in CADCraft code + (`canvas.rs`, `vpscale.rs`, `render/lib.rs`) means a paper-space layout viewport. + +## 8. Fonts, glyphs, language, RTL and accessibility + +### 8.1 Fonts and glyph coverage + +* S2 enables `default_fonts` and `monochrome_emoji_fonts` on the workspace `egui` + dependency. Without the latter, `⌘` (U+2318) has no bundled face (it was in + `emoji-icon-font`, previously unconditional). This is a visible glyph change and + therefore part of the compatibility PR, not a later “no behaviour change” stage. +* `⇧` (U+21E7) is only in Hack (Monospace), while `Button::shortcut_text` uses the + Button text style (Proportional body in CADCraft). Decide in S2: draw the macOS + modifier symbols in code (the WCS chevron already is, `canvas.rs:2558`), keep `⇧` + only in monospaced contexts, or use text on macOS. Do not silently enable tofu. +* Split the coverage test by source, because bundled fonts cannot cover every catalog: + * bundled-font sweep: every non-ASCII character in `i18n/*.tsv` that is not in a + `can_show`-gated script, plus `shortcut_label` outputs, must have a glyph in the + Proportional or Monospace family under `default_fonts` + `monochrome_emoji_fonts`, + without the system-font prepend (deterministic on CI/web); + * pinned-fixture sweep: Arabic/Thai/CJK/Japanese/Hebrew/color-emoji fixtures from + §8.4 loaded into a test `Context` cover the gated languages’ catalog characters. + * `theme::can_show` (`theme.rs:640-647`) remains the gate for `ar`/`zh*`/`ko`; + record which languages are gated and which fixtures were installed. +* Keep `theme::install_fonts`' caps (`MAX_UI_FONT_BYTES`) and file reads unchanged. + Do not adopt the 0.37 `GlyphRasterizer`/`FontProvider` APIs; set + `NativeOptions::system_font_fallback = false` explicitly and do not enable eframe's + `system_fonts` feature. This keeps CADCraft's capped app-controlled chain + authoritative and avoids the #8512 system-font discovery cost. +* Keep `FontsView` access test-only through `view.characters(...)`/`view.has_glyph`. + +### 8.2 Language and RTL + +* In `CadApp` (frame tick or first UI pass, before the AccessKit tree is built), call + `ctx.set_language(Some(language.to_owned()))` when the resolved language changes + (fork E17). This places the BCP 47 tag on the AccessKit root; the web runner's + speech/hidden-input language uses it through E18. +* Do not adopt `craft-i18n`; CADCraft owns its TSV catalog and `with_language`. +* Arabic: keep Arabic **text** rendering (bidi + shaping, E5/E12/E13) and verify + `ar.tsv` screens. `Style::right_to_left` (E9) mirrors egui menus, scroll bars, + checkboxes, combo boxes and popups, but not CADCraft's custom chrome or docking. + S4 keeps `right_to_left = false` (Arabic UI stays LTR with correctly shaped text, + documented in `docs/localization-parity.md`). S5b may enable it only with app-owned + chrome mirroring and craft-ui `DockArea::direction`; until then do not claim an RTL UI. +* Logical text is authoritative. `crates/fonts/src/bidi.rs`/`mtext.rs` compute visual + runs for rendering from logical text (`visual_runs`, `char_levels`); clipboard, + DXF/DWG save, accessibility and search continue to use logical strings. Never feed + display-bidi (visual-order) text into shaping, the clipboard or `IMEOutput`. + Test an Arabic MTEXT: copy, save/reopen and compare the logical string; no doubled + bidi and no reordered clipboard. +* AccessKit language test: after switching to `pt-BR`/`ar`, assert the root node's + language and `Options::language`; text-run direction assertions belong to fork E17. + +### 8.3 Accessible names and roles (S2, not S4) + +* kittest 0.37 defaults `check_accessibility = true` and + `missing_glyph_policy = MissingGlyphPolicy::Panic` + (`egui_kittest/src/builder.rs:50-51`); `check_accessibility()` runs after every + `run`/`run_steps` (`lib.rs:519-520`). Existing `Harness` tests therefore cannot be + green if an input is unnamed or a glyph is missing. +* Known failure: `alias_dialog.rs:35-37` renders the Alias dialog; its filter is + `ui.text_edit_singleline(&mut st.filter)` (`aliasdlg.rs:164-165`) after a separate + `ui.label("Filter")` with no `labelled_by`/placeholder. Name it in S2. +* Use `Response::labelled_by(label_response.id)` or + `ui.label_inputs_by(label.id, |ui| …)` for rows whose label CADCraft draws; + `hint_text` is acceptable for placeholder-labelled search fields, not numeric entry. +* Keep `icons::describe_control` labels/tab roles; migrate `WidgetType` to + `Role::Button` and assert `get_by_role`/`get_by_label` for a toolbar button, a toolset + tab and a dialog input. +* Add one test per dialog group that opens the dialog and asserts + `harness.unnamed_widgets()` is empty. Do not disable `check_accessibility` globally. + If a third-party widget cannot be named, name it at the call site and record why; a + per-test `with_accessibility_check(false)` requires an explicit enumerated exception, + a linked issue and a removal condition, not a suite-wide switch. +* S2 runs the full `cargo xtask ci` plus `cargo test -p cadcraft-ui-egui --all-targets` + and keeps `MissingGlyphPolicy::Panic`; S4 adds broader language/AccessKit-language + coverage, not the first green gate. + +### 8.4 Pinned licensed font fixtures + +Use the fixtures pinned by the egui plan §10.6; fetch them into the task evidence +directory, never commit them into CADCraft, and retain adjacent licences. CADCraft’s +own policy remains: optional central `craft-fonts` via `CRAFT_FONTS_DIR`, no font +binaries in this repository, no new `ATTRIBUTION.md` row for test-only external files. + +| Fixture | Source/path | SHA256 | License | +|---|---|---|---| +| Arabic | craft-fonts `2481a8a324b986a45954e90045ed1f8f2efaaf3c` `fonts/noto-sans-arabic/NotoSansArabic.ttf` | `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` | OFL-1.1 | +| Thai | craft-fonts `fonts/noto-sans-thai/NotoSansThai.ttf` | `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` | OFL-1.1 | +| CJK SC/Latin | craft-fonts `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` | `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` | OFL-1.1 | +| Japanese | craft-fonts `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` | `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` | OFL-1.1 | +| Hebrew | google/fonts `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5` `ofl/notosanshebrew/NotoSansHebrew[wdth,wght].ttf` | `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` | OFL-1.1 | +| Color emoji | googlefonts/noto-emoji `22e564626297b4df0a40570ad81d6c05cc7c38bd` `fonts/NotoColorEmoji.ttf` | `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` | OFL-1.1 | + +Bundled Latin/emoji comparisons use the exact `epaint_default_fonts` bytes from the +pinned baseline/candidate: `Ubuntu-Light.ttf` +`80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` and +`NotoEmoji-Regular.ttf` +`415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5` (record licence +files). Fix the Hebrew variable axes to weight 400/width 100. All fixture hashes and +licences enter the evidence manifest; no script may silently skip for a missing fixture. + +## 9. Clipboard, PRIMARY, IME and keyboard + +* Add `"clipboard"` to the desktop `eframe` features and assert + `cargo tree -i arboard` reaches `egui-winit`. Regression: Copy in CADCraft, paste + into another app; copy text elsewhere, paste into CADCraft (the DXF-text mirror + remains in `clipboard.rs`). The feature-graph assertion is the detector; a kittest + copy/paste assertion cannot prove the OS clipboard because kittest does not run + eframe's OS clipboard. +* **Required PRIMARY design.** The final winit pin must contain W15P (PRIMARY on the + existing connection/event queue, per-seat lifecycle, SCTK 0.19.2 + `PrimarySelectionManagerState`/device/source/offer, no second regular + `wl_data_device`), and the final egui pin must contain E4/P2b routing + `get_primary_text`/`set_primary_text` through it. The current candidate winit + `8b35388b` has no PRIMARY path, and the current candidate egui `a4f9a17f` lacks the + later P2b integration; neither is acceptable as the final pin. +* PRIMARY acceptance (native, on wlroots/Hyprland, KWin, Mutter, plus X11): + pointer- and keyboard-triggered ownership (including keyboard-only focus), serial freshness, owner/seat changes, empty/absent offers, + self-paste (marker MIME, no deadlock), canceled/slow transfers, unchanged selection + not reclaimed, password selection never offered. CADCraft defaults to XWayland when + `DISPLAY` is set (`main.rs:301-306`); `CADCRAFT_WAYLAND=1` is the native Wayland + exposure. A PRIMARY-only fallback is allowed only as a **measured** fallback with + explicit owner acceptance, not as the default limitation. +* Middle-click: `Event::MiddleClickPaste { pos, text }` exists in 0.37. CADCraft's + command line is custom-painted, not a `TextEdit`, so decide in S5a: handle it only + when the pointer is in the command-line bar rect (stored state), insert at the buffer + end, and never start a command or duplicate a `Paste`; otherwise document the gap. + `Event::PasteImage` is ignored (no image paste). +* **E21b/P7b is a new prerequisite, not existing IME behavior.** Winit W27 exposes + `set_ime_surrounding_text`, but candidate egui-winit never calls it and + `IMEOutput` has no logical text. The final egui pin must contain E21b/P7b: an + optional transient bounded payload (≤4000 UTF-8 bytes, UTF-8-boundary safe, + preedit excluded, byte cursor/anchor remapped), a built-in `TextEdit` producer, and + a Wayland adapter sending it on text/selection/IME-owner change; clear on password, + focus loss, no IME, unavailable context and reset. Keep the final IME descriptor and + payload together across passes. It is transient and excluded from serialized debug/ + persistence output. CADCraft has no custom `IMEOutput` producer (all editors use + built-in `TextEdit`; the MTEXT editor and rename fields inherit it), so no app + model adapter is required unless one is added later. The suite inventory's custom + producers (DesignCraft/FigCraft/WordCraft/VectorCraft/EffectCraft/FilmCraft/ + DeckCraft/PhotoCraft) do not apply here: CADCraft's custom-painted command line + receives IME commits as `Event::Text` and produces no `IMEOutput`. If a custom + producer is added, + it must supply logical model text and convert its own cursor coordinates to UTF-8 + bytes; `None` placeholders are not a port. +* IME tests: `Event::Ime(ImeEvent::Commit)` into a layer-rename field and the MTEXT + editor; outgoing surrounding text updates immediately after delete+commit and + deletion-only batches; scalar/grapheme vs byte indices; supplementary emoji, + combining marks and RTL logical order; preedit exclusion and caret movement; a + 4000-byte/long-selection bound; same-window field switch and two windows; focus + loss/reset/late serial; password/no-context clear; multi-pass final-output selection. + Native Wayland Vietnamese/predictive IME verifies the protocol trace and editor + content/undo. macOS NSRange/W26 remains a separate unqualified row. +* Keep `cmdline::capture_tab` (`cmdline.rs:56-74`) and synthetic injection. Tab capture + only when `egui_wants_keyboard_input()` is false; Escape/Enter still reach the + command line (`hidden_command_line.rs`). +* Non-Latin layouts: fork E7/E19 and winit W25 report Latin letters for shortcuts; test + Cmd/Ctrl shortcuts with a Russian/Arabic layout sequence and the winit unit tests. +* Clipboard `ctx.copy_text` is an output side effect: keep the serial-guarded mirror in + the first UI pass so a two-pass frame cannot copy twice. + +## 10. GPU, wgpu, stencil and Windows + +* CADCraft uses `egui-wgpu` paint callbacks, not a custom renderer; the callback trait + is unchanged. Keep `gpu::install` error/device-lost handlers and the stencil check. +* The canvas draws at `info.screen_size_px` and relies on egui's scissor for clipping + (`gpu.rs:731-780`). Fork E2+E3 fits an oversized surface to + `max_texture_dimension_2d` and returns configure/render errors instead of panicking; + CADCraft consumes it through eframe. Add a fork `surface_fit` unit test; a real + oversized window cannot be produced in the available environment, so record it as + an unresolved hardware gate. +* Windows: `graphics::configure` forces DX12 + FXC and excludes GL. Upstream #8736 + reports FXC compile cost for 0.37's predictable-filter shader (~57 ms vs 14 ms, + about +40 ms startup). Measure CADCraft start-to-first-`ping` on the Windows VM + (fresh processes) and compare 0.36/stock 0.37/fork; report renderer construction + separately from first frame. Do not hide a regression by changing CADCraft's FXC + choice, and do not report compile duration as UI performance. +* Keep `NativeOptions.stencil_buffer = 8` (`main.rs:188,277`). The fork's surface fit + must preserve the matching stencil texture size. `gpu_viewport_stencil.rs` stays. +* Keep `WgpuConfiguration::default().with_surface_config(surface_config(...))` + (`gpu.rs:53-65`) and the `max_buffer_size` adapter workaround (`main.rs:289-297`). +* Web: `?cpu` bypasses the GPU canvas; `?webgl` forces GL; the web runner passes + `None` for stencil (`apps/cadcraft-web/src/main.rs:133-137`). Do not change that + assumption without a WebGL2/browser test. +* Environment wording: this server has **no inherited display session and no running + compositor**. The parent can access an i915 PCI `8086:3E96` render node + (`/dev/dri/renderD128` readable/writable, `graphics-device-inspection.json`), but a + worker sandbox may hide `/dev/dri`, and access alone is not a qualified Vulkan/ + OpenGL/real-GPU result. Windows remains the Microsoft Basic Render Driver + (software D3D12). Report software and real-GPU tables separately. + +## 11. Dependencies, fork patches and vendors + +### 11.1 Target pins (final revisions unresolved) + +Before pinning, the fork and winit revisions must be reachable from the organisation +repositories; Cargo fetches a repository by `rev`, so a commit that exists only in +`aiongg/*` is not publishable as `storytold/*`. Publication is a separate human +approval. The final revisions are placeholders until that approval: + +```toml +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "", features = ["serde"] } +craft-docpath= { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "" } +egui = { version = "0.37.0", features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_kittest = "0.37.0" +eframe = { version = "0.37.0", default-features = false } +egui-wgpu = "0.37.0" +``` + +and one root redirect for exactly the crates in the graph: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +winit = { git = "https://github.com/storytold/winit", rev = "" } +``` + +Trim any crate not in the graph; an unused `[patch]` entry is a Cargo **warning**, not +a build error, but it is still removed. `egui_extras` is only a dev-dependency of +`craft-ui` and is not in CADCraft's graph. No `branch` and no `path` may appear for a +published pin; the audit's local path patches are evidence only. The final fork/tag +must also replace the fork's own root `[patch] winit = { branch = ... }` with the +reviewed winit rev, or document an immutable equivalent. + +`rust-version`: keep the workspace at `1.90` for engine crates and add +`rust-version = "1.95"` to `apps/cadcraft` and `apps/cadcraft-web`, matching +`crates/ui-egui/Cargo.toml:7` and `README.md:126-127`. + +### 11.2 craft-libs PR #199: port hunks, never the tree + +`git diff --numstat 49190884 b68de8c -- crates/ui` is **78 insertions, 222 deletions** +(14 files). The stale trees `b68de8c`/`ec8840a` are missing current `craft-platform` +and revert docking/printing/security fixes (`c67b1e1` floating-group permission, +`4f85efb` bounded `shown_names`, and related settings/printer hardening). Never merge +or check them out as the target. + +S1 creates a fresh branch from current `main` `49190884` and applies only the egui-API +hunks from `8614e4a` (plus the relevant README change from `1764bca`): + +* `WidgetType`→`Role`; +* `UiBuilder::id`/`Ui::id`→`scope_id`; +* `Id::new`→`Id::unique`; +* dev-deps on the final egui 0.37 revision (also bump workspace egui and UI egui_extras/egui_kittest dev-dependencies together); +* the docking fixture's U+22EE→`…` workaround; +* the CL-2 capture-slot prototype and CL-3 opt-in `DockArea::direction` API. + +Preserve the current docking-permission and print-name hardening. Run +`cargo test -p craft-ui --all-features`, the workspace tests, strict clippy and the +all-feature wasm check; merge; publish an immutable commit; then pin CADCraft to it. +`craft-docpath` is unchanged between `82a24b0` and `49190884`, so both CADCraft +craft-libs dependencies can use the same new revision. + +### 11.3 Language work #198 + +Consume #198 through the final fork/winit pins (E5/E12-E21, W17-W27, plus E21b/W27); +do not add a `craft-i18n` dependency or adopt its UI model. CADCraft keeps its TSV +catalog and `with_language`, and uses only `Context::set_language` and +`Style::right_to_left` from the fork. Any app-side #198 adoption is separate. + +### 11.4 Fork patch disposition for CADCraft + +Provenance: `egui/PATCHES.md` on the candidate and commits `0.37.0..a4f9a17f`. The +final pin must carry the whole reviewed series; the table records what CADCraft +exercises and what would catch removal. + +| Patch | What it does | CADCraft use | Keep / remove condition | Test | +|---|---|---|---|---| +| E1 | eframe `ViewportHooks` | Not used (no secondary windows) | Keep for craft-tablet; remove if upstream adds it | fork unit test | +| E2+E3 | fit oversized wgpu surface; setup errors | via eframe wgpu | Keep while emilk/egui#8361 open | fork `surface_fit`; CADCraft GPU gate unresolved | +| E4 | Wayland clipboard via winit | `CADCRAFT_WAYLAND=1` | Keep while #8657 draft; **requires W15 and W15P PRIMARY** | regular + PRIMARY native matrix | +| E5/E12/E13 | bidi shaping/order/marks/caret, graphemes | Arabic UI/text | Keep while #8577 open | fork text tests; Arabic UI label/MTEXT | +| E6 | IME focus not interrupted | Text fields | Keep | kittest IME commit | +| E7 | Windows VK letter keys | Non-Latin shortcuts | Keep; needs W25 | Windows VM | +| E8 | TextEdit glyph overshoot | Thai/Vietnamese/Arabic input | Keep | tall-glyph TextEdit | +| E9 | `Style::right_to_left` chrome | §8.2 decision | Keep; activation staged | fork RTL chrome; CADCraft opt-in only | +| E10 | web key/lang | Browser keys | Keep | browser/manual | +| E11 | vello_cpu single-thread raster | Not used | Keep | fork | +| E14/E15/E16 | fallback baseline, line breaker, cursor affinity | CJK/Indic editing | Keep | fork; CJK screens | +| E17 | `Context::set_language`, AccessKit language, bidi runs | Used in S4 | Keep until upstream | root-language test | +| E18 | eframe `Frame::set_ui_language`/web fallback | Web speech/lang | Keep | web/manual + language test | +| E19 | Latin layout chars for shortcuts | Non-Latin layouts | Keep; needs W25 | Windows/Linux manual | +| E20 | widget text in Ui language | All translated widgets | Keep | language render test | +| E21 | IME delete-surrounding bytes | macOS/Wayland IME | Keep; needs W26/W27 | fork + native IME | +| E21b | bounded outgoing IME surrounding text + TextEdit producer + Wayland adapter | All built-in `TextEdit` editors | **Required new prerequisite**; keep with W27 state/serial | outgoing-state/focus/password/UTF-8/4000-byte tests | +| P2b | E4 PRIMARY integration | Wayland PRIMARY | Required with W15P | native PRIMARY matrix | + +Each “remove when” is a review condition: remove a patch only after the replacement is +verified in the same consumer graph and the table is updated. + +### 11.5 winit + +CADCraft needs the winit fork because E4/E19/E21/E21b call APIs in W15/W15P/W25/W26/ +W27 not in crates.io 0.30.13. Patch winit to the **final reviewed rev**, not a branch; +keep `winit` 0.30.13-compatible `dpi`. `apps/cadcraft/Cargo.toml:29` keeps its direct +winit dependency for `EventLoopBuilderExtX11`; the patch makes it and eframe's winit one +package. + +Do not delete W1-W15 or W17-W27 tests (`tests/ime_patches.rs`, +`tests/pointer_patches.rs`). W15P needs pure seat/offer/serial/owned-marker tests, a +`WAYLAND_DEBUG=1`/test-compositor protocol trace, and the native Hyprland/KWin/Mutter +matrix. W16 (pinch/rotation) remains outside this cut. + +### 11.6 Vendors and lockfile checks (native and wasm) + +* CADCraft has no vendor directory and no external path patch. Nothing is deleted. +* craft-libs' `vendor/krilla`, `vendor/hayro*` are PDF-only and unrelated; leave them. +* pdfcraft's vendored `egui-wgpu`/winit are another repository's responsibility; do not + remove them here or copy their patches into CADCraft. +* After regeneration, assert on both target feature sets: + * native: `cargo tree -d`, `cargo tree -i egui`, `cargo tree -i winit`, + `cargo tree -i arboard`, `cargo metadata --format-version 1`; + * wasm: `cargo tree --target wasm32-unknown-unknown -d`, + `cargo tree -i egui --target wasm32-unknown-unknown`, + `cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web`, + and `cargo metadata` filtered/interpreted for `apps/cadcraft-web`; + * no duplicate egui/emath/ecolor/epaint/accesskit 0.36/0.37 pairs; + * every egui-family source is `git+https://github.com/storytold/egui?rev=`; + winit is the storytold fork; craft-libs crates share one final rev; + * `harfrust` may legitimately appear at 0.12 (egui) and 0.14 (`cadcraft-fonts`); + * `arboard` is present through `egui-winit` because `clipboard` is on; + * the wasm graph has `egui/web_file`, eframe wgpu/accesskit and no native-only + `arboard`, X11 or Wayland packages; assert these, and test a negative fixture with + a missing family patch or a moved branch (it must fail the graph gate); + * no manifest contains `path = "/home/…"` or a `branch =` for a published pin; + * `cargo check --locked` succeeds on the final native and wasm locks. +* Keep the old coherent lock (`a08fd7c`) in evidence for rollback; do not delete the old + dependency set from review notes. + +Shared handoff requirement: consume complete egui P8 including P7c's selected #8763/#8748 +fixes and P7 key-release pairing. MTEXT selection-to-format assertions and ordinary held-key +layout/focus changes are S5/S6 gates. Built-in TextEdit's E21b producer inherits P7b's owner +identity even when unavailable, Available-empty distinction, committed W27 clear, latest-pass +None, stale-epoch invalidation and current-epoch serial-mismatch behavior. Add a native test +contrasting stale epoch with current-epoch mismatch; do not implement fork internals locally. + +## 12. Staged implementation PRs + +Publication order assumes each repository has its own approval. No stage is pushed, +tagged, merged or posted by this plan. + +``` +storytold/winit final rev (bounded W15/W15P, W17-W27 lifecycle) + -> storytold/egui final P8 (P2b/P7/P7b/P7c included) + -> craft-libs S1: current main + PR199 API hunks + CL-2F/CL-3 + -> published craft-libs final rev + -> cadcraft S2 (atomic compatibility; strict names/glyphs; clipboard/fonts; + PanelState migration; native+wasm graph) + -> S3 lifecycle/screenshot/host + -> S4 language/AccessKit/RTL text + -> S5a PRIMARY/clipboard matrix (required for parity) + -> S5b RTL activation (optional, only with chrome mirroring) + -> S6 docs + acceptance evidence +``` + +* **S1 craft-libs** — fresh branch from `49190884`; apply only the `8614e4a` API + hunks; preserve docking-permission/printing hardening; add CL-2 capture slot and + CL-3 opt-in direction API; tests/clippy/wasm; merge; publish a final rev. +* **S2 CADCraft compatibility** — final pins; `clipboard`, `default_fonts`, + `monochrome_emoji_fonts`; app `rust-version`; C1-C8 mechanical migrations; panel + `PanelState` migration; accessible names and glyph sweep; lockfile regeneration; + shared CL-2F callback migration including basic drain/timeout/reply handling and + native **and wasm** graph assertions. It is atomic and must compile and pass + `cargo xtask ci` (and `cargo test -p cadcraft-ui-egui --all-targets`). It is **not** + a “no behaviour change” stage: the emoji font, accessible names and panel keys change. +* **S3 CADCraft lifecycle** — `frame_tick` split, opening spinner move, host/web frame + token, once-per-frame wheel application, shared capture lifecycle stress, dropped-file fix, + bounded counters, tests and mutation probes. +* **S4 CADCraft text/accessibility/language** — broader glyph coverage via pinned + fixtures, `Context::set_language`, AccessKit language, Arabic shaping checks, docs + row for RTL status. +* **S5a CADCraft clipboard/PRIMARY (required for parity)** — middle-click decision and + the native Wayland/X11 PRIMARY matrix against W15P/E4/P2b. A measured fallback is + recorded only if native evidence proves the design cannot meet a required platform. +* **S5b CADCraft RTL activation (optional)** — `DockArea::direction`, + `Style::right_to_left` and app-owned chrome mirroring; otherwise document the gap. +* **S6 evidence and docs** — performance report, screenshots, before/after matrices, + docs revision history, remaining gates. + +Rollback: keep the `a08fd7c` source/lock pair as the last coherent 0.36.2 state. S2 is +the compatibility boundary; after it, revert the stack (source + lock) rather than the +lock alone. S3, S4, S5a and S5b are independent commits and can be reverted without touching the pins. +No partial rollback to 0.36 is promised after S2 until the stack is reverted as a unit. + +## 13. Verification plan + +### 13.1 Compilation and repository gates (through `task.py run/status/finish`) + +* `cargo xtask ci` (fmt, clippy -D warnings, workspace tests, assets, layers, wasm). +* `cargo check --workspace --all-targets --locked`. +* `cargo test -p cadcraft-ui-egui --all-targets` **alone** (UI crate dev/test font + features self-sufficient). +* `cargo check -p cadcraft-web --target wasm32-unknown-unknown` and + `trunk build --release` from `apps/cadcraft-web`; the wasm32 target is installed, + but `trunk`/`wasm-bindgen` and a browser are not, so the bundle/browser run remain + blocking unknowns. +* Native **and** wasm graph checks from §11.6. +* Optional features: `cargo test -p cadcraft-ui-egui --features …` where the crate has + features (currently none); craft-libs all-features/craft-ui all-features in S1. +* Windows: a task-specific craft-windows clone, separate target/evidence, native + `cargo test --workspace --locked`, `cargo build --release -p cadcraft`, then the GUI + workflow in §13.5. Do not reuse the baseline executable as new-source evidence. + +### 13.2 Behavioural regressions + +| Behaviour | Assertion | Anchor | +|---|---|---| +| Multi-pass frame side effects | one non-idempotent counter/LISP slice/control execution per frame with a popup open | §6.5 | +| Hidden logic | control replies, autosave deadlines and screenshot timeouts while minimized/unknown visibility; dropped file opens once | §6.5 | +| Fresh queues | a control request queued between passes is answered without waiting for the next frame | §6.5 | +| Hidden child (conditional) | if a later fork pin adopts an overdue full pass, parent occluded/overdue plus child open/update/close; CADCraft currently has no child viewports, so record N/A rather than claim #8748 | §6.5, §14.5 | +| Screenshot correlation | two tokens/two paths; timeout/cancel/admission/geometry mismatch | §6.4 | +| Command/undo | create → undo → redo → entity count | engine + UI control test | +| Save/reopen | `examples/apartment.dxf` opened, edited, saved to temp, reopened; entity count and file bytes | `apps/cadcraft`/CLI | +| Theme/language | light/dark/System, `en`/`de`/`zh`/`ar`, no text loss; AccessKit language | `lib.rs`/`i18n` tests | +| Panels/docking | canvas rect, tab order, floating group move, workspace reload, unchanged direction JSON | `docking_workflows`, `chrome_geometry` | +| Keyboard/focus | Tab/Shift-Tab, Enter/Space activation, Escape/Enter command line, IME commit | `workspace_focus`, `hidden_command_line`, new IME | +| Clipboard | Copy/Cut/Paste across apps, DXF mirror, X11/Wayland PRIMARY matrix | `clipboard.rs`, native | +| Selection | MTEXT toolbar formats the exact selected range; pointer and keyboard paths | `#8763`/`#8764` test | +| GPU/stencil | clipped viewport pixels, surface/stencil sizes, device-loss notice | `gpu_viewport_stencil`, `gpu_faults` | +| Fonts/glyphs | bundled sweep + pinned fixtures; no tofu/clipped combining marks | §8.1/§8.4 | +| Web | `?cpu`, `?webgl`, open/download/iframe host | browser matrix | + +### 13.3 Screenshots, geometry and semantic accessibility + +* Capture before/after PNGs for 1600×1000 and 900×560, 1.0/1.25/2.0 scale, dark/light, + Start tab and a drawing with Layers/Properties/Tool Sets open, an in-window menu, the + command line with keywords, MTEXT editor, Layer Properties Manager and Options. +* Compare geometry semantically first (panel rects, canvas rect, text bounding boxes, + no overlap), then pixels with stated tolerances: exact for flat fills/sampled + backgrounds; changed-pixel ratio ≤0.5% per window and no text box moving more than + 2 px unless the change is the intended 0.37 style adjustment. Record both images and + diff counts; never update a golden without a stated intent. +* Accessibility: `unnamed_widgets()` empty for every opened dialog, expected + role/label/value for representative inputs, tab traversal order, focus visibility and + AccessKit root language after a switch. +* kittest `with_context_setup` is the supported way to install deterministic fonts/ + options before the first pass (#8686); do not use `Context::run_logic` to initialise + fonts or read input twice. + +### 13.4 Mutation probes + +Break the implementation once per feature and record the failure: +1. move only the deliberately non-idempotent LISP slice/counter into a second-pass + call; the two-pass test must fail (the other queue work is consume-based and would + still run once, so it is not a valid probe); +2. map two screenshot tokens to one path; the correlation test must fail; +3. remove the `clipboard` feature; the **`cargo tree -i arboard`** assertion must fail, + and a manual OS-clipboard check must fail (a kittest copy/paste assertion is not a + detector); +4. put dropped-file handling back in `logic`; the hidden-tick/reopen test must fail; +5. remove an accessible name; `unnamed_widgets` must fail; +6. change `Role::Button` to `Role::Unknown`; the role test must fail; +7. remove `monochrome_emoji_fonts`; the `⌘` glyph test must fail; +8. leave `DockArea` and `cancel_drag` ids different; the drag-cancel test must fail; +9. skip the panel `PanelState` migration; the seeded old-key test must fail; +10. reapply the wheel delta in pass two; the two-pass view-position assertion must fail; +11. send a screenshot completion after resize/DPI change; the mismatch test must fail; +12. remove the 4000-byte IME bound/preedit exclusion; the IME tests must fail. + +A probe is not a deliverable; record the failure output in evidence. + +### 13.5 Platform matrix + +| Platform | What can be qualified here | Limits / required preparation | +|---|---|---| +| Linux host, headless | compile, clippy, tests, kittest, `cadcraft-cli perf`, wasm compile | no inherited `DISPLAY`/`WAYLAND_DISPLAY`, no compositor/Xvfb; not native focus/IME/picker/GPU evidence | +| Linux X11 | functional GUI and focus/clipboard after provisioning Xvfb + a window manager; record software renderer/versions | Xvfb/manager absent at inspection; must be provisioned as an isolated managed job; results labelled virtual-display/software | +| Linux Wayland | W15/W15P/PRIMARY, W27/E21b IME after provisioning Weston/Sway/wlroots/KWin/Mutter | compositors absent; XWayland default path is separate; native capability evidence required | +| Linux GPU | parent-visible i915 `8086:3E96` renderD128; qualify Vulkan/OpenGL/wgpu via a managed probe | worker sandbox may hide `/dev/dri`; access alone is not GPU acceptance; software and real-GPU tables separate | +| Windows craft-windows VM | native compile/tests, GUI launch, screenshots, control channel, Basic Render Driver | software D3D12; IME/keyboard layouts only if the guest has them; no real-GPU claim | +| Browser | `cargo check`/`trunk build`; interactive run needs a pinned browser and local server | no browser/trunk installed; WebGPU/WebGL backend must be recorded | +| macOS | release CI only, no local hardware | native focus/IME/picker/traffic-light layout unresolved | +| FreeBSD | CI workflow/VM only | no local evidence | + +Native focus/IME/file-picker behavior and real-GPU performance are not established by +headless rendering or the software renderer. macOS/browser rows remain unresolved unless +real evidence is produced. + +## 14. Performance plan + +### 14.1 Environment and identities + +Record for every run: exact CADCraft SHA, `Cargo.lock` SHA256, feature set, +`CRAFT_FONTS_DIR` (or absent), fixture (`examples/apartment.dxf`, synthetic counts), +OS/kernel, Rust version, GPU or software renderer/driver, display scale, viewport size, +build profile (`release`), and whether the canvas is GPU or `?cpu`. On Linux record +`lscpu`, `uname -a`, DRM device identities and `vulkaninfo --summary` when available, +plus the Xvfb/compositor/window-manager versions. On Windows record the guest adapter +and `dxdiag` Basic Render Driver. Never mix software and real-GPU tables. + +### 14.2 Baselines and attribution + +* **B0**: `a08fd7c` with the coherent 0.36.2 egui family and craft-ui `5c874d51`. +* **B0u**: B0 in a measurement-only worktree with `frame_nr`/pass/CPU counters and + idle-frame instrumentation backported (never committed). +* **B0e**: `1977a493` engine/format dependency state measured with `cadcraft-cli perf` + (engine-only, no UI) to attribute tiny-skia/skrifa/harfrust/quick-xml changes. +* **C0**: the S2 compatibility commit. It includes the clipboard/font/accessibility/ + panel-key changes, so it is not a pure dependency baseline. +* **C0u**: C0 with the same measurement-only counters. +* **Cs**: C0's source compiled against **stock egui 0.37 `841a328b`** (and stock + craft-ui API port) where it compiles; if a fork-only API is already used, use the + same operation in a pinned minimal adapter/harness and explain the semantic + difference. Record B0/Cs/C0 source and lock identities. +* **C1..Cn**: each later stage, compared with C0 for that stage. + +Do not compare B0 directly with S4 as if only the UI changed. Use B0 vs B0e for the +non-UI delta, C0 vs Cs for fork-vs-stock, and B0 vs C0 for the total compatibility +delta. `ui.inspect` does not expose `cumulative_frame_nr`; C0u/B0u add the counter in +the measurement worktree, and S3 may add bounded production fields. `cpu_usage` is +available only from the host `eframe::Frame`, so the host records it; `CadApp::logic` +does not receive a `Frame`. + +### 14.3 Matched operations and sample counts + +Use the suite minimums: **≥10 warmups**, **≥30 steady samples** for discrete +operations, **≥20 fresh startup processes**, and **≥1,000 frames** before reporting a +frame p99. Interleave baseline/candidate (ABAB) on the same machine and lock; report raw +samples, median, p95, p99 (only with ≥1,000 frames), peak RSS, allocations/texture +uploads and idle CPU/repaint count. Exclude the first run after a cold shader/font cache; +record cold vs warm separately. + +1. **Engine control**: `cadcraft-cli perf 200000` and + `cadcraft-cli perf examples/apartment.dxf`; repeat the single-shot command in a + driver script (the CLI has no repeat flag), ≥10 warmups + ≥30 samples, parse the + timing table. +2. **Idle**: open the sample, no input, 60 s; frames from the measurement counter, + process CPU (`/proc//stat`, `Get-Process` CPU), peak working set; animations on + and off; ≥1,000 frames. +3. **Pan/zoom**: 200 view steps across five zoom bands via `cmdline.input` ZOOM/PAN or + `ui.drag` (there is no `view.set`), a redraw each; `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`; ≥1,000 frames. +4. **Selection/properties**: 10,000 synthetic entities, open Properties, pan, re-select; + `drawMs`/`frameMs`; include the `#8763`/`#8764` selection action. +5. **Command line/menus**: 200 commands and 50 menu opens; frame time and idle repaint. +6. **Dialogs**: open/close Layers, Options, Plot, MTEXT 20 times; frame time and + allocation/upload counters; ≥1,000 frames for the p99 claim. +7. **Save/reopen**: open `examples/apartment.dxf`, edit, save to temp, reopen; wall + time and `drawMs`. +8. **#8731 text layout/tessellation**: 120 ASCII labels + 120 rounded-rect controls + adapted to CADCraft's chrome; measure tessellation CPU/instruction cost per frame on + B0/Cs/C0; test mixed/color rows and keep color-emoji/wrap semantics. +9. **#8736 Windows startup**: fresh process, poll the control port until `ping` + succeeds; renderer construction separate from first frame; ≥20 fresh processes on + B0/Cs/C0. Record the absolute +ms for FXC. +10. **#8512 fonts**: measure CADCraft startup with `system_font_fallback=false` and + record that no system-font discovery is performed; compare a stock-eframe default + only as a separate row. Do not enable `system_fonts` to “fix” a number. + +### 14.4 Thresholds and noise policy + +Predefine and freeze before candidate measurement: + +* investigate a repeatable **>5% median** or **>10% p95** latency regression; +* investigate **>5% peak-memory** increase and any new idle busy-loop/audio underrun; +* for sub-millisecond measurements require the relative increase to exceed **0.05 ms** + or corroborate with stable instruction/allocation counts; +* app-specific gates may be stricter, never looser. CADCraft engine operations + ≤ +5% median and UI `frameMs`/`cpuMs` p95 ≤ +10% are the floor. Startup uses the same + median/p95 rule; **no “+100 ms” allowance** hides the ~+40 ms FXC cost. If +40 ms is + accepted, it is a documented tradeoff with owner review, not a threshold change. +* Same Rust/toolchain, `release`, features/fonts/fixture, GPU/driver/DPI/window size; + no concurrent builds/noisy workload; rerun noisy comparisons; never expand a + tolerance after seeing a failure; never report compile duration as UI performance. +* Screenshots use the §13.3 pixel policy; a 0.37 style change (#8617/#8445) is documented + with a before/after crop before acceptance. + +### 14.5 Upstream-reported issues (reported, not measured here) + +* #8731: plain-text tessellation ≈+60% in 0.37, color-glyph branch in + `Tessellator::tessellate_text`; measure operation 8 on B0/Cs/C0; a row-level fast path + is a fork decision, not a CADCraft workaround. +* #8736: `egui-wgpu` 0.37 `wrap_texel_coord` makes the DX12/FXC pipeline ~4× slower to + compile, ~+40 ms startup; CADCraft defaults to FXC. Measure operation 9; do not + change the FXC choice to hide it. Clamp-only is not a valid Repeat/MirroredRepeat fix. +* #8512: system-font startup cost; CADCraft disables the provider, so record absence of + that cost rather than adding it. +* #8595: repaint-burst fix may reduce idle CPU; measure operation 2. +* #8528: cross-viewport galley cache; CADCraft has one viewport, not applicable. +* #8763/unmerged #8764: selection loss before a clicked widget. Test the MTEXT toolbar + and dialog actions with pointer and keyboard; the fork owns any selected fix, and an + app workaround must preserve the range and undo semantics with a removal condition. +* #8748: **closed and unmerged**; not behavior in the pin. Test root hidden/logic-only + behavior; a deferred-child overdue full pass exists only if a later fork pin adopts + it, with that exact revision and native evidence. Do not infer phase from `visible()`. + +## 15. Documentation updates + +* `docs/ui-parity.md`: Accessibility row — AccessKit roles/names/language tested in + kittest; native screen-reader coverage still not established. +* `docs/localization-parity.md`: Arabic row — shaping/RTL status, whether the UI is + mirrored, glyph coverage; add a revision-history line. +* `docs/control-protocol.md`: new `ui.inspect.perf` fields (frame/pass counters) and + unchanged screenshot client semantics; note screenshot admission/timeout errors. +* `README.md`: UI builds require Rust 1.95; keep the core 1.90 minimum. +* No `ATTRIBUTION.md`, contributor or asset changes are part of this migration. Keep + `@skylartaylor` username-only credit. + +## 16. Risks, blocking unknowns and acceptance + +Blocking before calling the migration complete: + +1. **Final pins/publication reachability**: only the approved final `storytold/egui`, + `storytold/winit` and `storytold/craft-libs` revisions are production pins. The + aiongg candidates are comparison inputs. The final fork/tag must replace the fork's + own moving winit branch patch. +2. **W15P/E4 PRIMARY**: the final winit rev must include W15P and the final egui rev + E4/P2b, with native wlroots/KWin/Mutter/X11 evidence. A PRIMARY-only fallback is a + measured, explicitly accepted exception. +3. **E21b/P7b IME**: the final egui rev must include the bounded outgoing surrounding + text producer/adapter; otherwise IME is not complete and the limitation is explicit. +4. **craft-libs S1**: port only `8614e4a` hunks onto `49190884`; never merge + `b68de8c`/`ec8840a`; preserve `c67b1e1`/`4f85efb`/`craft-platform` and README. +5. **Toolchain**: egui 0.37 requires Rust 1.95; apps must state it. The server's + default `rustc` is 1.99.0 and 1.95.0/1.96.1/1.97.1 are installed; `trunk`, a browser + and a compositor/Xvfb are absent. +6. **Display/renderer**: no inherited display; parent-visible i915 renderD128 is + unqualified and may be hidden from a worker; Windows is software D3D12. Native focus, + IME, picker and real-GPU performance remain unresolved until managed evidence. +7. **Web**: `cargo check -p cadcraft-web` and a wasm graph gate are required; `trunk` + build and a real browser run are blocking unknowns. +8. **Windows/macOS native**: Basic Render Driver only; focus/IME/picker/traffic-light + layout and real-GPU behavior unqualified; macOS hardware unavailable. +9. **Fonts**: monochrome emoji changes `⌘`; the `⇧` decision may change macOS labels; + pinned fixtures are external and must keep their licences. `can_show` gating remains. +10. **RTL**: `Style::right_to_left` alone does not mirror CADCraft chrome; use craft-ui + `DockArea::direction` only with app-owned mirroring; do not claim an RTL UI earlier. +11. **Screenshot on web**: `now_ms` is 0 on wasm; the new slot deadline must use a web + clock, and a browser screenshot remains a separate gate. +12. **Oversized surface**: no environment can produce a window larger than + `max_texture_dimension_2d`; E2+E3 is covered by the fork unit test only. +13. **Review/evidence**: a fresh independent adversarial review of this exact document + SHA256 is required before implementation; each implementation stage needs a fresh + adversarial review at its exact candidate revision, with regressions and measured + before/after performance. The baseline is captured before production edits. + +Acceptance criteria (all evidenced, not inferred): S2 compiles with one egui family and +one winit; `cargo xtask ci` passes with strict kittest names/glyphs; native and wasm +graph assertions pass; S3 lifecycle/screenshot/bounds tests and mutation probes pass; +S4 language/AccessKit/Arabic tests pass; S5a PRIMARY is evidenced or recorded +as an explicit fallback; before/after screenshots and performance samples meet §14.4 or +have a reviewed explanation; remaining platform gates are listed with the exact +unverified behaviors. + +## References (primary sources inspected) + +* egui 0.37.0 changelog (`CHANGELOG.md` at `841a328b`) and crate sources: + `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `PanelState`/`Panel::id`, + `ScreenshotCallback`, `MouseWheelSource`, `Role`, `Id`/`AsId`/`AsIdSalt`, + `FontDefinitions`, `MissingGlyphPolicy`, kittest `builder.rs`/`accessibility.rs`. +* Native/web eframe integrations: `native/epi_integration.rs`, `native/run.rs`, + `native/wgpu_integration.rs`, `web/app_runner.rs` at `a4f9a17f` and `767b1cad`. +* Candidate fork `PATCHES.md` and commits `0.37.0..a4f9a17f`; winit plan W15P and + `craft/0.30.13-lang`; craft-libs `49190884`, `8614e4a`, `b68de8c`, `ec8840a`. +* `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json` + (#8731, #8736, #8763, #8748, #8535, #8489, #8555, #8583, #8512, #8428, #8657, + #8577, #8641), `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, + `graphics-device-inspection.json`, and `craft-libs/docs/egui-037-suite-rollout.md`. + These are reported findings/history, not fresh measurements. + +## Appendix A — source anchors + +Manifests: `Cargo.toml:6-11,52-59`; `crates/ui-egui/Cargo.toml:7,20-32`; +`apps/cadcraft/Cargo.toml:19,29`; `apps/cadcraft-web/Cargo.toml:20,26-32`. + +App shell: `apps/cadcraft/src/main.rs:67-110` (App impl), `:185,188` (app id/stencil), +`:228-307` (options/run), `:343-348` (set_wgpu), `:301-306` (XWayland). +`apps/cadcraft-web/src/main.rs:31-58` (Shell), `:108-130` (web renderer/stencil), +`apps/cadcraft-web/src/upload.rs:70-77` (`take_dropped`). +`apps/cadcraft/src/workspace.rs:149-170` (save_if_changed/save_on_exit). + +UI lifecycle: `crates/ui-egui/src/lib.rs:195-267` (CadApp fields), `:444-455` +(finish_opening), `:536-598` (logic), `:601-625` (raw_input_hook), `:627-662` +(ui/draw), `:664-687` (drain_control), `:688-740` (screenshots), `:745-755` (now_ms). +`crates/ui-egui/src/opening.rs:77-97` (spinner Area). + +UI structure: `chrome.rs:69,180,300` (panels), `menus.rs:569-589` (menu bar), +`docking.rs:74-130,318-399` (Workspace/DockArea), `palettes.rs:330,346` (panels), +`theme.rs:314-387` (fonts), `:640-647` (can_show), `:697-713` (theme apply), +`animation.rs:75-130` (wheel/unsmooth), `icons.rs:948-989` (buttons/roles), +`cmdline.rs:56-74` (Tab), `:251-330` (keyboard), `clipboard.rs:29-60` (mirror/paste), +`mtext_editor.rs:146-164,217-224,420-455` (formatting toolbar/selection). + +GPU/control: `gpu.rs:53-65` (surface config), `:681-780` (install/callback), +`control.rs:195-215` (methods), `:255-272` (inspect), `:547-559` (save_screenshot), +`tests/gpu_viewport_stencil.rs`, `tests/gpu_faults.rs`. + +Tests: 43 files under `crates/ui-egui/tests`; see §3.7; `tests/chrome_geometry.rs:58` +uses 1200×760/900×560; `tests/hidden_command_line.rs:19-30` is the `run_ui` helper shape. + +## Appendix B — commands proposed for the parent to run (not run here) + +All build/test commands must go through `maintenance/task.py run/status/finish` with one +unique task and evidence path, e.g.: + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-cadcraft-ci-01 --kind build --task egui037-cadcraft \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/cadcraft \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/cadcraft \ + -- bash -c 'cargo xtask ci' +``` + +and equivalents for: + +```sh +cargo check --workspace --all-targets --locked +cargo test -p cadcraft-ui-egui --all-targets +cargo check -p cadcraft-web --target wasm32-unknown-unknown +(cd apps/cadcraft-web && trunk build --release) +cargo tree -d +cargo tree -i egui +cargo tree --target wasm32-unknown-unknown -d +cargo tree -i egui --target wasm32-unknown-unknown +cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web +cargo metadata --format-version 1 +cargo run -p cadcraft-cli -- perf 200000 +cargo run -p cadcraft-cli -- perf examples/apartment.dxf +``` + +Use the craft-windows clone helper for the Windows stage, a fresh target/evidence +directory, `guest-exec.py`/build equivalents appropriate to CADCraft, and the existing +`task.py`/`vm.py` sequence. Do not reuse the baseline's built binary as new-source +evidence, and do not run these commands in this planning task. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/cadcraft` `main` now resolves to `f043540d13dac03ce895a96c78a8349f3e40bd5b`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve browser page zoom; test canvas pinch routing because this page does not add DesignCraft touch-action:none. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation update — 2026-10-24 (upstream review v9 resolutions) + +The v9 source review found no high-severity migration regression in the native UI +library/integration gate and listed four conservative-behaviour notes plus three +strict-glyph/accessibility gaps. This update records how the concrete findings are +resolved without widening the migration contract: + +* **Conservative capture identity stays.** `SurfaceGeometry` equality keeps + `window_focused`, `minimized` and `occluded` alongside viewport, pixels-per-point + and texture limits. Any observed focus/visibility transition can change the + presented frame, so an admitted capture is rejected rather than re-used; the + struct's documentation now states this intent instead of narrowing the epoch. +* **No-op `document.switch` stays strict.** `run_switch` bumps the document epoch + even when the requested index is already active, because the command cancels the + active command and an external client cannot prove the switch was unobservable. + The source records that the conservative bump is deliberate. +* **Invalid pass geometry invalidates the retained surface.** `note_pass_geometry` + now mirrors `note_raw_geometry`: a pass that cannot report a finite positive + pixels-per-point clears `capture_geometry` and bumps both epochs. Previously the + previous surface survived and a later admission could bind to stale geometry. +* **MTEXT Diameter is painted, not shaped.** U+2300 is absent from egui's bundled + Proportional/Monospace/emoji faces and from the web font set. The toolbar button + now paints CADCraft's own circle-and-slash glyph and carries the semantic name + "Diameter"; the document text still uses U+2300, which the drafting font covers. +* **Affected controls are named.** The MTEXT height `DragValue` gets the AccessKit + name "Text height" (its tooltip alone is not a name), and QuickCalc's conversion + `Value` field is associated with its visible label and a meaningful placeholder + instead of the unexplained "1". +* **Strict bundled-font regressions.** Focused kittest regressions render the + Diameter button, MTEXT height field and QuickCalc value row with bundled + `FontDefinitions` only (no `theme::install_fonts` system prepend), the default + `MissingGlyphPolicy::Panic` and accessibility checks. No global tofu policy or + accessibility opt-out is introduced. + +The first macOS native-menu poll now refreshes enablement immediately by +initializing that timer to `f64::NEG_INFINITY`; `now_ms` starts near zero, so the +former `0.0` sentinel suppressed the first ~300 ms of enablement. + +This amendment is source-only: it does not claim a cargo, fmt, clippy, test, wasm, +Windows/macOS or GUI result. Publication still requires fresh explicit approval +after the parent's own validation. + +### Full MTEXT toolbar font regression + +The bundled-font check also covers the entire formatting toolbar: alignment, list and tab +marks are drawn with original geometry, and the color button relies on egui’s menu affordance +instead of an uncovered arrow character. The font and color controls publish semantic names. +The whole-toolbar harness retains strict missing-glyph and accessible-name checks, so a system +font cannot hide an unsupported toolbar character. Native visual review remains required. + + +## Native MTEXT insertion follow-up (2026-10-24) + +Native Wayland job29 exercised real keyboard circle creation and MTEXT entry, the +Diameter button, callback screenshot, save, normal quit, restart and reopen. The +new entities and exact `Migration 0.37 ⌀` text survive DXF reopening. The editor +showed tofu for U+2300 even though its toolbar icon was already painted correctly. +A bounded original circle/slash `GlyphRasterizer` now supplies only U+2300 when no +installed font covers it; it keeps the stored Unicode character and cursor indexes +unchanged and works without optional font files on native/web. Font setup installs +it idempotently. A real toolbar-click/TextEdit test uses the strict bundled-font +harness and checks inserted contents and caret. Job30 validates this follow-up; +fresh native visual confirmation and review remain pending. diff --git a/packaging/env.sh b/packaging/env.sh index ab01ce4c5..07d69a068 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -49,7 +49,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index e70d80009..75a6ff690 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml @@ -50,6 +50,8 @@ modules: - install -Dm644 packaging/linux/ai.storyteller.cadcraft.desktop /app/share/applications/ai.storyteller.cadcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.cadcraft.mime.xml /app/share/mime/packages/ai.storyteller.cadcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm644 NOTICE /app/share/doc/cadcraft/NOTICE + - install -Dm644 ATTRIBUTION.md /app/share/doc/cadcraft/ATTRIBUTION.md - >- version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); install -d /app/share/metainfo; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 36b69cecb..7ad0c4016 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -103,7 +103,6 @@ fi if has appimage; then APPDIR="$WORK/CADCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/cadcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index fd626fbf4..014f91bd9 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/cadcraft" "$APP/Contents/MacOS/CADCraft" cp "$ROOT/assets/app-icon/cadcraft.icns" "$APP/Contents/Resources/CADCraft.icns" +# Bundled dependency font notices are installed before the app is signed. +cp "$ROOT/NOTICE" "$ROOT/ATTRIBUTION.md" "$APP/Contents/Resources/" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). copy_font_licences "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ diff --git a/packaging/windows/cadcraft.wxs b/packaging/windows/cadcraft.wxs index 50f82f6ff..d59cc41b3 100644 --- a/packaging/windows/cadcraft.wxs +++ b/packaging/windows/cadcraft.wxs @@ -14,6 +14,9 @@ unless DESKTOPSHORTCUT=1 or DESKTOPSHORTCUT=0 is given. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index a6b8cf23e..92b48e134 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -92,11 +92,27 @@ Copy-Item (Join-Path $Bin 'cadcraft.exe'), (Join-Path $Bin 'cadcraft-cli.exe') $ & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'cadcraft.exe') (Join-Path $Stage 'cadcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +# Bundled dependency font notices are required even without optional craft-fonts. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE'), (Join-Path $Root 'ATTRIBUTION.md') -Destination $Docs +$HasCraftFontLicences = 1 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "cadcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'cadcraft.wxs') -arch $Arch -ext WixToolset.UI.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\cadcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. @@ -108,7 +124,7 @@ $Portable = Join-Path $TargetDir "windows-package\cadcraft-$Version-windows-$Arc Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { +foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } From bbbd9eec512c8a0eedb788fe63c771b16ea01844 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:06:35 -0400 Subject: [PATCH 39/91] craft-pdf and its PDF backends back at craft-libs 6e7f285 (#2147) The egui 0.37 migration moved craft-pdf, krilla, hayro-syntax and hayro-jbig2 to 7895f5e, 206 commits older, though none of them depends on egui. 6e7f285 keeps krilla patch 13 (non-finite link and page-box geometry refused), craft-libs#282 (URI links only for http(s) and mailto) and craft-libs#255 (CCITT and JPEG 2000 decode bounds). The embedded ICC profiles are identical at both revisions. --- ATTRIBUTION.md | 8 ++++---- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- NOTICE | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 99990cbab..4b9ba5eb5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | -| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | -| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | -| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | -| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 6ab372714..34b7ffe6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,7 +961,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-syntax", "krilla", @@ -1855,7 +1855,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-ccitt", ] @@ -1869,7 +1869,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2215,7 +2215,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 293a1b367..8334b1630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ cadcraft-mcp = { path = "crates/mcp" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } # foundation thiserror = "2" @@ -91,9 +91,9 @@ debug = 0 # PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself # (Cargo ignores dependency-local patches). [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # Keep the complete egui family and native event bridge on reviewed fork commits. ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 811da09b1..e800c1d45 100644 --- a/NOTICE +++ b/NOTICE @@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc -(pinned through https://github.com/storytold/craft-libs/tree/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc). +(pinned through https://github.com/storytold/craft-libs/tree/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc). CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ -Pinned licence text: https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/LICENSE +Pinned licence text: https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/LICENSE The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts and registered through egui's default font definitions. They derive from emoji-icon-font by From 0a14fc7d3ae84cf229fcc5733efc9bdebcbdaa22 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:07:15 -0400 Subject: [PATCH 40/91] macOS menu choices and their shortcuts run at once (#2140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS menu choices and their shortcuts run at once A menu choice is no window event, so the app ran it only at its next frame for another reason: File ▸ Close (Cmd+W) on an edited drawing showed "Save changes?" and dimmed the floating palettes a second or two late. The menu's event handler now wakes the app. * cargo fmt after merging main --- apps/cadcraft/src/main.rs | 2 +- apps/cadcraft/src/native_menu.rs | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 6c9af80a8..a9bdb343a 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -78,7 +78,7 @@ impl eframe::App for App { #[cfg(target_os = "macos")] { if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { - self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); + self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx)); } if let Some(m) = &mut self.native_menu { m.poll(&mut self.cad, ctx); diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 523d38a52..1e987870d 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::mpsc; use cadcraft_ui_egui::menus::{self, Entry}; use cadcraft_ui_egui::{CadApp, i18n}; @@ -22,6 +23,7 @@ pub struct NativeMenu { /// The recent files File ▸ Open Recent lists now (it is rebuilt when they change). recent: Vec, counter: usize, + events: mpsc::Receiver, } /// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works). @@ -34,7 +36,15 @@ fn accel(sc: &str) -> Option { impl NativeMenu { /// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus. - pub fn install(app: &mut CadApp) -> Self { + pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self { + // A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not + // wake the app for it: the handler does, and the choice runs in the next frame. + let (sender, events) = mpsc::channel(); + let wake = ctx.clone(); + MenuEvent::set_event_handler(Some(move |event| { + let _ = sender.send(event); + wake.request_repaint(); + })); let menu = Menu::new(); let mut items = HashMap::new(); let language = app.language(); @@ -70,7 +80,17 @@ impl NativeMenu { submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, submenus, predefined, language, last_refresh: f64::NEG_INFINITY, recent: app.ui.recent_files.clone(), counter } + Self { + _menu: menu, + items, + submenus, + predefined, + language, + last_refresh: f64::NEG_INFINITY, + recent: app.ui.recent_files.clone(), + counter, + events, + } } /// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native @@ -93,7 +113,7 @@ impl NativeMenu { /// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed. pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { - while let Ok(ev) = MenuEvent::receiver().try_recv() { + while let Ok(ev) = self.events.try_recv() { if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { if cmd == "ui.discord" { ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); From 1d9ddd240630230c08463536eaef3b22c64d1f0b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:07:54 -0400 Subject: [PATCH 41/91] "Save changes?" and Drawing Recovery appear at once (#2144) An egui modal showed a frame late (its first frame only measured it) and then faded in, dim included, over 0.2 s. These alerts answer a key press or menu choice, so they now skip the fade, and their first show reruns the pass with the measured size, which draws them in the first frame presented. --- crates/ui-egui/src/autosave.rs | 2 +- crates/ui-egui/src/closing.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/modal.rs | 14 ++++++++ crates/ui-egui/tests/it/instant_modals.rs | 41 +++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/modal.rs create mode 100644 crates/ui-egui/tests/it/instant_modals.rs diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index e63bf1cbf..d062f69e9 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -150,7 +150,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut open = None; let mut discard = None; let mut later = false; - egui::Modal::new(egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| { + crate::modal::instant(ctx, egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| { ui.set_width(460.0); ui.label(RichText::new("Drawing Recovery").strong()); ui.add_space(4.0); diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index 9e4f35ed8..04d526fa2 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { }; let t = Tokens::get(); let mut choice = None; - egui::Modal::new(egui::Id::unique("cc_close_prompt")).show(ctx, |ui| { + crate::modal::instant(ctx, egui::Id::unique("cc_close_prompt")).show(ctx, |ui| { ui.set_width(360.0); ui.label(RichText::new(crate::tl!("CADCraft")).strong()); ui.add_space(4.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3799dc36..a157a05aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub mod icons; pub mod layers; pub mod managers; pub mod menus; +pub mod modal; pub mod opening; pub mod options; pub mod palettes; diff --git a/crates/ui-egui/src/modal.rs b/crates/ui-egui/src/modal.rs new file mode 100644 index 000000000..10c2ea562 --- /dev/null +++ b/crates/ui-egui/src/modal.rs @@ -0,0 +1,14 @@ +//! Alerts that appear in the frame that asks for them. +//! +//! An `egui::Modal` normally shows a frame late and then fades in over `animation_time` +//! (0.2 s): its area's first frame only measures it and draws nothing, and the dim behind it +//! fades with it. An alert that answers a key press or menu choice should be there at once. + +/// A modal with no fade-in. On its first show it asks egui to run the pass again with the +/// measured size, so the first frame presented already draws it. +pub fn instant(ctx: &egui::Context, id: egui::Id) -> egui::Modal { + if egui::AreaState::load(ctx, id).is_none() { + ctx.request_discard("an instant modal's first show only measures it"); + } + egui::Modal::new(id).area(egui::Modal::default_area(id).fade_in(false)) +} diff --git a/crates/ui-egui/tests/it/instant_modals.rs b/crates/ui-egui/tests/it/instant_modals.rs new file mode 100644 index 000000000..7a7797803 --- /dev/null +++ b/crates/ui-egui/tests/it/instant_modals.rs @@ -0,0 +1,41 @@ +//! "Save changes?" is fully drawn, dim included, in the frame of the Close that asks it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui::epaint::Shape; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +/// The opacity of each painted text containing `needle`. +fn text_opacity(shapes: &[egui::epaint::ClippedShape], needle: &str) -> Vec { + fn walk(shape: &Shape, needle: &str, out: &mut Vec) { + match shape { + Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, needle, out)), + Shape::Text(text) if text.galley.text().contains(needle) => out.push(text.opacity_factor), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|s| walk(&s.shape, needle, &mut out)); + out +} + +#[test] +fn close_prompt_is_fully_drawn_in_the_frame_of_the_menu_choice() { + let mut app = CadApp::new(Session::default(), Services::default()); + app.ui.start_tab = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + h.run_steps(2); + menus::activate(h.state_mut(), "close"); + h.step(); + assert_eq!(text_opacity(&h.output().shapes, "Save changes to"), [1.0]); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2e2451579..5faf3135b 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -12,6 +12,7 @@ mod drafting_spacing; mod file_tab_titles; mod gpu_viewport_stencil; mod hidden_command_line; +mod instant_modals; mod lisp_files; mod measure_distance_snapping; mod menubar_hover; From b37b7ceb7b5042a82b4ad90e2795fb78144d3410 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 12:13:04 -0400 Subject: [PATCH 42/91] refactor: replace wildcard imports with explicit ones (S2208, batch_09) (#2046) Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/cmd/view/named_views.rs | 3 ++- crates/engine/src/cmd/view/vports.rs | 3 ++- crates/geom/src/property_tests.rs | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/view/named_views.rs b/crates/engine/src/cmd/view/named_views.rs index ef2750ef2..f88d240db 100644 --- a/crates/engine/src/cmd/view/named_views.rs +++ b/crates/engine/src/cmd/view/named_views.rs @@ -10,7 +10,8 @@ use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; use super::super::qselect::wildcard; -use super::super::*; +use super::super::{CommandSpec, bad, point_param, str_param}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step, View}; /// Most names one Delete or list takes. diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index 88ee0a6ed..5b95f720a 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -15,7 +15,8 @@ use cadcraft_geom::Vec2; use serde_json::{Value, json}; use super::super::qselect::wildcard; -use super::super::*; +use super::super::{CommandSpec, bad, find_command, str_param}; + use crate::{Accept, DocState, EngineError, Input, Interactive, ModelTile, ModelTiles, Prompt, Result, Session, Step, View}; /// Most tiles on screen at once. diff --git a/crates/geom/src/property_tests.rs b/crates/geom/src/property_tests.rs index f4ebb55f3..0584c9109 100644 --- a/crates/geom/src/property_tests.rs +++ b/crates/geom/src/property_tests.rs @@ -4,7 +4,9 @@ //! runs a few thousand cheap cases; the known-answer part of the suite works at 1e6–1e7 //! coordinates, where double precision starts to show (geometry-parity.md §Tests). -use crate::*; +use crate::{ + Arc, Bounds2, Circle, Line, Segment, Vec2, circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite, +}; // ---------- generator ---------- From 5ec3a18aa7674e00e2147dcaebd83f0145682249 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:07 +0100 Subject: [PATCH 43/91] Escape literal \U+ and \M+ backslashes in the DXF writer (#2050) Found while testing crates/dxf/src/lib.rs:511-524 (escape_unicode) and 226-258 (unescape_unicode, applied in parse_ascii_with_limit at 278) Co-authored-by: Claude Haiku 5.5 --- crates/dxf/src/lib.rs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 82ecf55e8..907c5abf7 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -413,8 +413,7 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { for t in tags { let _ = write!(out, "{:>3}\r\n", t.code); match &t.value { - Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })), - Value::Str(s) => escape_unicode(s, &mut out), + Value::Str(s) => escape_unicode(s, utf8, &mut out), Value::Real(r) => out.push_str(&format_real(*r)), Value::Int(i) => out.push_str(&i.to_string()), Value::Bool(b) => out.push_str(&i64::from(*b).to_string()), @@ -446,11 +445,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { v => v.as_str(), }; let mut text = String::with_capacity(s.len()); - if utf8 { - text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })); - } else { - escape_unicode(&s, &mut text); - } + escape_unicode(&s, utf8, &mut text); out.extend(text.bytes().filter(|&b| b != 0)); out.push(0); } @@ -507,12 +502,16 @@ pub fn is_binary(bytes: &[u8]) -> bool { /// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII /// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes -/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces. -fn escape_unicode(s: &str, out: &mut String) { - for c in s.chars() { +/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are. +/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader +/// gives back the literal text. Line breaks become spaces. +fn escape_unicode(s: &str, utf8: bool, out: &mut String) { + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { match c { '\r' | '\n' => out.push(' '), - c if c.is_ascii() => out.push(c), + '\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"), + c if utf8 || c.is_ascii() => out.push(c), c => { let mut units = [0u16; 2]; for u in c.encode_utf16(&mut units) { @@ -569,6 +568,19 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec)> { mod tests { use super::*; + #[test] + fn literal_escape_text_roundtrips() { + for utf8 in [false, true] { + for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] { + let tags = vec![Tag::s(8, text)]; + let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap(); + assert_eq!(back, tags, "utf8={utf8} {text}"); + let back = parse_binary(&write_binary(&tags, utf8)).unwrap(); + assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}"); + } + } + } + #[test] fn ascii_roundtrip() { let tags = vec![ From 097c53bee3203b465e5992dea9b20f2360467fc9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:11 +0100 Subject: [PATCH 44/91] Ignore last vertex bulge in open Polyline::centroid (#2053) Found while testing crates/geom/src/curve.rs:568-581 (Polyline::centroid); compare area() at 533-538 Co-authored-by: Claude Haiku 5.5 --- crates/geom/src/curve.rs | 4 +++- crates/geom/src/tests.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 72fe3ac0c..2b799e433 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -569,7 +569,9 @@ impl Polyline { let (Some(a), Some(b)) = (self.vertices.get(i), self.vertices.get((i + 1) % n)) else { continue }; let (pa, pb) = (a.p - origin, b.p - origin); add(pa.cross(pb) / 2.0, (pa + pb) * (1.0 / 3.0)); - if let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) { + if (i + 1 < n || self.closed) + && let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) + { let t = arc.sweep(); let seg = arc.radius * arc.radius * (t - t.sin()) / 2.0; if seg > 0.0 { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 8b8a0bad2..a452e610d 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -331,3 +331,13 @@ fn long_secant_keeps_both_intersections() { assert!(hits[0].0.near(Vec2::new(-2.0, 0.0), 1e-8), "{hits:?}"); assert!(hits[1].0.near(Vec2::new(2.0, 0.0), 1e-8), "{hits:?}"); } + +#[test] +fn open_polyline_centroid_ignores_last_bulge() { + let pts = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)]; + let mut pl = Polyline::from_points(&pts, false); + let before = pl.centroid().unwrap(); + pl.vertices[2].bulge = 1.0; + let after = pl.centroid().unwrap(); + assert!(before.near(after, 1e-9), "{before:?} -> {after:?}"); +} From a0a691e1f7e03c890c988993d00e54b91232b481 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:14 +0100 Subject: [PATCH 45/91] Reject leading '+' in hex handle and colour parsing (#2067) Found while testing crates/doc/src/entity.rs:21 Co-authored-by: Claude Haiku 5.5 --- crates/doc/src/entity.rs | 15 ++++++++++++++- crates/ui-egui/src/options/mod.rs | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 87c7e45b7..0d1f5eb5f 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -18,7 +18,11 @@ impl Handle { format!("{:X}", self.0) } pub fn parse_hex(s: &str) -> Option { - u64::from_str_radix(s.trim(), 16).ok().map(Handle) + let s = s.trim(); + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u64::from_str_radix(s, 16).ok().map(Handle) } } @@ -1477,6 +1481,15 @@ mod grip_tests { use super::*; use cadcraft_geom::PolyVertex; + #[test] + fn handle_parse_hex_accepts_only_hex_digits() { + assert_eq!(Handle::parse_hex("1a"), Some(Handle(0x1A))); + assert_eq!(Handle::parse_hex(" 1A "), Some(Handle(0x1A))); + for bad in ["+1A", "-1A", "", "+", "1 A"] { + assert_eq!(Handle::parse_hex(bad), None, "{bad}"); + } + } + #[test] fn polyline_grips_are_vertices_and_midpoints() { // Grips are each vertex plus the midpoint of each segment; the segment vector is computed diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options/mod.rs index 37ee2c404..c8b94c310 100644 --- a/crates/ui-egui/src/options/mod.rs +++ b/crates/ui-egui/src/options/mod.rs @@ -107,7 +107,7 @@ pub fn hex(c: Color32) -> String { /// Parse `"#rrggbb"` (the `#` is optional). pub fn parse_hex(s: &str) -> Option { let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); @@ -387,7 +387,7 @@ mod tests { let c = Color32::from_rgb(0x12, 0xab, 0xff); assert_eq!(parse_hex(&hex(c)), Some(c)); assert_eq!(parse_hex("FFFFFF"), Some(Color32::WHITE)); - for bad in ["", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] { + for bad in ["#+f+0+0", "", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] { assert_eq!(parse_hex(bad), None, "{bad}"); } } From 36fcc896f7ebaf8e4edcf92f659546a0798b3b20 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:18 +0100 Subject: [PATCH 46/91] GETVAR CECOLOR reads back as text, as in AutoCAD (follow-up to #2014) (#2069) CECOLOR now reads "BYLAYER", "BYBLOCK", the index as text ("1" after "red") or "RGB:r,g,b" for a true colour, from getvar, LISP and the SETVAR ? list, where it was the ACI number (256, 0, 1, or the nearest index of a true colour). The header still keeps the index and CECOLOR_RGB, so DXF output and new objects are unchanged. SETVAR CECOLOR takes "RGB:r,g,b" too, so a value saved with getvar can be set back, true colour included. --- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/props.rs | 20 ++++++++- crates/engine/src/lisp/tests.rs | 9 ++++ crates/engine/src/sysvars.rs | 4 +- crates/engine/tests/it/setvar_cecolor.rs | 57 +++++++++++++++++++++--- 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 74bc7f697..1856c6a15 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -33,7 +33,7 @@ pub mod plotters; mod props; pub use draw2::breakline::BreaklineOptions; pub use props::MatchProps; -pub(crate) use props::set_current_color_value; +pub(crate) use props::{current_color_value, set_current_color_value}; mod qselect; mod selecttools; mod settings; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index c89204c22..0150c3390 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -431,7 +431,11 @@ fn run_color(s: &mut Session, p: &Value) -> Result { pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> { // An out-of-range index is refused here, where `color` folds it to ByLayer. let c = match v { - Value::String(t) => Color::parse(t), + // "RGB:r,g,b" is how `getvar` reads a true colour back, so a saved value can be restored. + Value::String(t) => match t.trim().get(..4).filter(|p| p.eq_ignore_ascii_case("rgb:")).and_then(|_| t.trim().get(4..)) { + Some(triple) => Color::parse(triple).filter(|c| matches!(c, Color::True(_))), + None => Color::parse(t), + }, Value::Number(n) => n.as_i64().and_then(|i| Color::parse(&i.to_string())), _ => None, }?; @@ -439,6 +443,20 @@ pub(crate) fn set_current_color_value(s: &mut Session, v: &Value) -> Option<()> Some(()) } +/// `getvar CECOLOR` as AutoCAD gives it, always text: "BYLAYER", "BYBLOCK", the index ("1" for +/// red) or "RGB:r,g,b". +pub(crate) fn current_color_value(d: &cadcraft_doc::Drawing) -> String { + if let Some(rgb) = d.header.get("CECOLOR_RGB").and_then(|v| v.as_i64()).and_then(|v| u32::try_from(v).ok()) { + let cadcraft_color::Rgb(r, g, b) = cadcraft_color::Rgb::from_u32(rgb); + return format!("RGB:{r},{g},{b}"); + } + match Color::from_aci(d.header.i64("CECOLOR", 256) as i16) { + Color::ByLayer => "BYLAYER".into(), + Color::ByBlock => "BYBLOCK".into(), + other => other.to_aci().to_string(), + } +} + /// A true colour keeps its RGB in CECOLOR_RGB (which wins for new objects); any other colour must /// clear a stale one. fn set_current_color(d: &mut cadcraft_doc::Drawing, c: Color) { diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 820a9812b..0d8e12ce0 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -455,6 +455,15 @@ fn getvar_drawing_and_session_variables() { assert_eq!(ok(&mut s, "(getvar \"DWGTITLED\")"), "1"); } +/// CECOLOR is a string in AutoCAD, whatever it was set from (#1999). +#[test] +fn getvar_cecolor_is_a_string() { + let mut s = session(); + assert_eq!(ok(&mut s, "(list (getvar \"CECOLOR\") (type (getvar \"CECOLOR\")))"), "(\"BYLAYER\" STR)"); + assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"red\") (getvar \"CECOLOR\")"), "\"1\""); + assert_eq!(ok(&mut s, "(setvar \"CECOLOR\" \"10,20,30\") (getvar \"CECOLOR\")"), "\"RGB:10,20,30\""); +} + #[test] fn input_functions_take_supplied_answers() { let mut s = session(); diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 042185772..e22529aac 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -146,6 +146,8 @@ pub fn get(s: &Session, name: &str) -> Option { // Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG. "ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()), "CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)), + // Text, as AutoCAD reads it back; the header keeps the index (and CECOLOR_RGB) for DXF. + "CECOLOR" => json!(crate::cmd::current_color_value(s.doc().ok()?)), // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), @@ -447,7 +449,7 @@ pub fn list(s: &Session) -> Value { } if let Ok(d) = s.doc() { for (k, v) in &d.header.vars { - if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT") { + if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") { m.insert(k.clone(), get(s, k).unwrap_or(Value::Null)); } else if !k.starts_with("CADCRAFT_") { m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null)); diff --git a/crates/engine/tests/it/setvar_cecolor.rs b/crates/engine/tests/it/setvar_cecolor.rs index a8347ddcb..e37ac2966 100644 --- a/crates/engine/tests/it/setvar_cecolor.rs +++ b/crates/engine/tests/it/setvar_cecolor.rs @@ -1,5 +1,6 @@ //! `setvar CECOLOR` takes what the `color` command takes: "ByLayer", "ByBlock", a colour name or an -//! index (#1999). +//! index (#1999). `getvar CECOLOR` reads it back as AutoCAD does, as text: "BYLAYER", "BYBLOCK", +//! the index ("1" for red) or "RGB:r,g,b". use cadcraft_engine::Session; use cadcraft_engine::doc::color::{Color, Rgb}; @@ -14,22 +15,66 @@ fn new_line_color(s: &mut Session) -> Color { s.doc().unwrap().model.last().unwrap().common.color } +#[test] +fn getvar_cecolor_of_a_new_drawing_is_bylayer() { + let mut s = Session::new(); + assert_eq!(cecolor(&mut s), json!("BYLAYER")); +} + #[test] fn setvar_cecolor_accepts_names() { - for (value, aci) in [("BYLAYER", 256), ("ByLayer", 256), ("BYBLOCK", 0), ("red", 1), ("250", 250)] { + for (value, shown) in [ + ("BYLAYER", "BYLAYER"), + ("ByLayer", "BYLAYER"), + ("256", "BYLAYER"), + ("BYBLOCK", "BYBLOCK"), + ("0", "BYBLOCK"), + ("red", "1"), + ("7", "7"), + ("250", "250"), + ("RGB:10,20,30", "RGB:10,20,30"), + ("10,20,30", "RGB:10,20,30"), + ] { let mut s = Session::new(); s.execute("setvar", &json!({ "name": "CECOLOR", "value": value })).unwrap_or_else(|e| panic!("{value}: {e}")); - assert_eq!(cecolor(&mut s), json!(aci), "{value}"); + assert_eq!(cecolor(&mut s), json!(shown), "{value}"); } } +#[test] +fn getvar_cecolor_after_the_color_command() { + let mut s = Session::new(); + s.execute("color", &json!({ "color": "ByBlock" })).unwrap(); + assert_eq!(cecolor(&mut s), json!("BYBLOCK")); + s.execute("color", &json!({ "color": "10,20,30" })).unwrap(); + assert_eq!(cecolor(&mut s), json!("RGB:10,20,30")); +} + +/// The value `getvar` gives back is one `setvar` takes, so saving and restoring keeps a true colour. +#[test] +fn getvar_cecolor_restores_a_true_colour() { + let mut s = Session::new(); + s.execute("color", &json!({ "color": "10,20,30" })).unwrap(); + let saved = cecolor(&mut s); + s.execute("setvar", &json!({ "name": "CECOLOR", "value": "250" })).unwrap(); + s.execute("setvar", &json!({ "name": "CECOLOR", "value": saved })).unwrap(); + assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30))); +} + +#[test] +fn setvar_cecolor_lists_cecolor_as_text() { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "CECOLOR", "value": "red" })).unwrap(); + assert_eq!(cadcraft_engine::sysvars::list(&s)["CECOLOR"], json!("1")); +} + #[test] fn setvar_cecolor_name_replaces_a_true_colour() { let mut s = Session::new(); s.execute("color", &json!({ "color": "10,20,30" })).unwrap(); assert_eq!(new_line_color(&mut s), Color::True(Rgb(10, 20, 30))); s.execute("setvar", &json!({ "name": "CECOLOR", "value": "ByLayer" })).unwrap(); - assert_eq!(cecolor(&mut s), json!(256)); + assert_eq!(cecolor(&mut s), json!("BYLAYER")); assert_eq!(new_line_color(&mut s), Color::ByLayer); } @@ -44,8 +89,8 @@ fn setvar_cecolor_number_replaces_a_true_colour() { #[test] fn setvar_cecolor_refuses_what_is_not_a_colour() { let mut s = Session::new(); - for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true)] { + for bad in [json!("chartreuse"), json!(257), json!(-1), json!(true), json!("RGB:"), json!("RGB:300,0,0")] { assert!(s.execute("setvar", &json!({ "name": "CECOLOR", "value": bad })).is_err(), "{bad}"); } - assert_eq!(cecolor(&mut s), json!(256)); + assert_eq!(cecolor(&mut s), json!("BYLAYER")); } From a4a3a342a0306d1fa4272754d1601a46e112c847 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:21 +0100 Subject: [PATCH 47/91] Fix format_angle zeroing radians near a full turn at low precision (#2073) Found while testing crates/engine/src/units.rs:124-127 (wrap_full_turn, used at line 106 for AUNITS 3 and at lines 105 and 120) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/units.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 892710890..777e6477c 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -136,9 +136,13 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String { } } -/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the same value as a full turn. +/// `v` (in `[0, full)`), or 0 when it rounds at `prec` decimals to the printed full-turn value (only when that value is exact). fn wrap_full_turn(v: f64, full: f64, prec: usize) -> f64 { - if format!("{v:.prec$}") == format!("{full:.prec$}") { 0.0 } else { v } + let full_text = format!("{full:.prec$}"); + // A full turn that cannot be printed exactly (2π) only wraps when `v` is numerically that value. + let exact = full_text.parse::().is_ok_and(|f| f == full); + let reached = exact || full - v < 1e-6; + if reached && format!("{v:.prec$}") == full_text { 0.0 } else { v } } /// Parse a distance typed by the user: decimal, `1'6"`, `1'-6 1/2"`, `6 1/2`, `3/4`, `1e3`. @@ -507,4 +511,11 @@ mod tests { assert_eq!(format_angle(angle, 4, 0), "S 74d E"); assert_eq!(format_angle(angle, 4, 6), "S 73d41'51.00\" E"); } + + #[test] + fn angle_radians_low_precision_not_wrapped() { + assert_eq!(format_angle(6.2, 3, 0), "6r"); + assert_eq!(format_angle(6.26, 3, 1), "6.3r"); + assert_eq!(format_angle(0.0, 3, 1), "0.0r"); + } } From a89be5f2dd73288b74f3b23c7d6cbb4621903802 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:25 +0100 Subject: [PATCH 48/91] A Fix on a derived point (a line's midpoint, an arc's end) holds the position it had before the edit (#2077) --- crates/constraints/src/dof.rs | 2 +- crates/constraints/src/lib.rs | 123 +++++++++++++++++++++- crates/constraints/src/model.rs | 12 +++ crates/constraints/src/tests.rs | 4 +- crates/engine/src/cmd/constraints.rs | 29 +++-- crates/engine/tests/it/fix_holds_grips.rs | 48 +++++++++ 6 files changed, 206 insertions(+), 12 deletions(-) diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index f0a34b9f8..559452223 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -235,7 +235,7 @@ mod budget_tests { aux_init: Vec::new(), aux: 0, }; - let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 }; + let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 }; assert!(analyse(&comp).dof.is_none()); } } diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 81e81fc25..7ee52f0a8 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -81,6 +81,12 @@ pub struct SolveOptions { pub keep: Vec, /// Entities that should take up the change. pub prefer_move: Vec, + /// Points held where they are for this solve, as a Fix would hold them (the grip an edit + /// just put down): see [`edited_points`]. + pub hold: Vec, + /// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is + /// now: see [`moved_anchors`]. + pub anchors: Vec<(u32, Vec2)>, } #[derive(Clone, Debug, Default, PartialEq, Serialize)] @@ -205,12 +211,19 @@ pub(crate) struct Comp { pub(crate) model: Model, pub(crate) built: Vec, weights: Vec, + /// Parameters of the points held by [`SolveOptions::hold`]. + held: Vec, tol: f64, } impl Comp { fn locks(&self, skip: Option) -> Vec { let mut locked = vec![false; self.model.x0.len()]; + for &j in &self.held { + if let Some(l) = locked.get_mut(j) { + *l = true; + } + } for (i, b) in self.built.iter().enumerate() { if Some(i) == skip || b.form != Form::Lock { continue; @@ -346,7 +359,14 @@ pub(crate) fn build_budgeted( for c in group { let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 }; match interpret(c, &mut model, target) { - Ok(b) => built.push(b), + Ok(mut b) => { + if let Form::Anchor(_) = b.form + && let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id) + { + b.form = Form::Anchor(a); + } + built.push(b); + } Err(msg) => skipped.push((c.id, msg)), } } @@ -393,8 +413,9 @@ pub(crate) fn build_budgeted( for w in weights.iter_mut().skip(geo) { *w = 1e-3; } + let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect(); let tol = scale_tol(&model.x0); - comps.push(Comp { model, built, weights, tol }); + comps.push(Comp { model, built, weights, held, tol }); } Ok((comps, skipped)) } @@ -445,6 +466,9 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option)> { /// A Fix keeps no position of its own: the solver locks the parameters at their current values. /// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with /// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored. +/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to +/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring +/// the point back. pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool { let refs: Vec = d .constraints @@ -461,10 +485,21 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool { let mut changed = false; for r in &refs { let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape); - let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue }; if !same_shape { continue; } + let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { + if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle)) + && translated(&now, &old, r.handle) + && let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) + { + for (v, &w) in dst.iter_mut().zip(src) { + *v = w; + } + changed = true; + } + continue; + }; for (i, j) in i.into_iter().zip(j) { if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j)) && *slot != v @@ -480,6 +515,88 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool { changed } +/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs. +fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> { + m.point_refs(h) + .iter() + .filter_map(|r| m.lock_indices(r)) + .flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::>()) + .collect() +} + +/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else? +fn translated(now: &Model, old: &Model, h: Handle) -> bool { + let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false }; + let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false }; + let tol = scale_tol(xb) * 10.0; + let pts: Vec<(usize, usize)> = + point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect(); + let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?)); + let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false }; + if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) { + return false; + } + (0..xb.len()) + .filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j)) + .all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol)) +} + +/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it +/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so +/// it stays where it was put and the rest of the object takes up the constraints. Empty when the +/// object kept its points or moved them all. +pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec { + let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h])); + if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) { + return Vec::new(); + } + let tol = scale_tol(&old.x0) * 10.0; + let refs = now.point_refs(h); + let moved: Vec = refs + .iter() + .filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol)))) + .copied() + .collect(); + if moved.len() == refs.len() { Vec::new() } else { moved } +} + +/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the +/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`]. +/// +/// A Fix on a derived point anchors it where it is when the system is built, and +/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point +/// would leave the Fix satisfied at the new place. +pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> { + let fixes: Vec<(u32, GeomRef)> = d + .constraints + .iter() + .filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c)) + .filter_map(|c| match c.refs.as_slice() { + [r] => Some((c.id, *r)), + _ => None, + }) + .collect(); + if fixes.is_empty() { + return Vec::new(); + } + let handles: Vec = fixes.iter().map(|(_, r)| r.handle).collect(); + let (now, old) = (Model::build(d, &handles), Model::build(before, &handles)); + let tol = scale_tol(&old.x0); + let mut out = Vec::new(); + for (id, r) in fixes { + let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape); + if !same_shape || now.lock_indices(&r).is_some() { + continue; + } + if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r)) + && !p.near(a, tol) + { + out.push((id, a)); + } + } + out +} + /// True when every interpretable constraint holds at the current geometry. pub fn all_satisfied(d: &Drawing) -> bool { let handles: Vec = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect(); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index ba7e58548..b85afd12f 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -477,6 +477,18 @@ impl Model { } } + /// The points of an object a Fix locks directly (ends, centre, vertices, insertion point). + pub fn point_refs(&self, h: Handle) -> Vec { + let Some(s) = self.slot(h) else { return Vec::new() }; + let at = |sub: Sub| GeomRef { handle: h, sub }; + match s.shape { + Shape::Line => vec![at(Sub::Start), at(Sub::End)], + Shape::Point => vec![at(Sub::Whole)], + Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)], + Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(), + } + } + /// Parameters a Fix on this reference locks directly. `None` when the reference is a derived /// point (fixed through a residual instead). pub fn lock_indices(&self, r: &GeomRef) -> Option> { diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 6db26c95e..35b73fea2 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() { let mut d = Drawing::default(); let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); let b = line(&mut d, (0.0, 2.0), (10.0, 4.0)); - add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap(); + add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap(); let (a0, a1) = ln(&d, a); assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}"); } @@ -412,7 +412,7 @@ fn glyphs_and_residuals() { }) .unwrap(); assert!(!all_satisfied(&d)); - let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap(); + let r = solve(&mut d, &SolveOptions::default()).unwrap(); assert_eq!(r.moved, vec![a]); assert!(all_satisfied(&d)); } diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index 54af88c80..41ded6d36 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -327,7 +327,7 @@ fn add_constraint(s: &mut Session, c: Constraint) -> Result { let keep: Vec = c.refs.first().map(|r| r.handle).into_iter().collect(); let prefer_move: Vec = c.refs.iter().skip(1).map(|r| r.handle).filter(|h| !keep.contains(h)).collect(); let mut d = s.doc()?.clone(); - let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move }).map_err(err)?; + let id = cs::add(&mut d, c, &cs::SolveOptions { keep, prefer_move, ..Default::default() }).map_err(err)?; *s.doc_mut()? = d; Ok(id) } @@ -1119,7 +1119,7 @@ fn infer_after(s: &mut Session, before: &Drawing) { return; } let mut nd = d.clone(); - let opts = cs::SolveOptions { keep: others, prefer_move: placed }; + let opts = cs::SolveOptions { keep: others, prefer_move: placed, ..Default::default() }; let added = cands.into_iter().take(200).filter(|c| cs::add(&mut nd, c.clone(), &opts).is_ok()).count(); if added > 0 && let Ok(st) = s.state_mut() @@ -1148,7 +1148,8 @@ pub fn after_command(s: &mut Session, before: Option<&Arc>, ok: bool) { let mut d = (*st.doc).clone(); let purged = cs::purge_invalid(&mut d); let restored = cs::restore_fixed(&mut d, before); - let satisfied = cs::all_satisfied(&d); + let anchors = cs::moved_anchors(&d, before); + let satisfied = anchors.is_empty() && cs::all_satisfied(&d); if purged.is_empty() && !restored && satisfied { return; } @@ -1167,9 +1168,25 @@ pub fn after_command(s: &mut Session, before: Option<&Arc>, ok: bool) { keep.push(h); } } - let mut trial = d.clone(); - match cs::solve(&mut trial, &cs::SolveOptions { keep, prefer_move: Vec::new() }) { - Ok(_) => d = trial, + // A grip drag moved some of an object's points: they stay where they were put, and the + // edited objects still move least. When that cannot be solved, the edited objects are + // only kept as a whole, as for any other edit. + let hold: Vec = keep.iter().flat_map(|&h| cs::edited_points(&d, before, h)).collect(); + let mut solved = None; + if !hold.is_empty() { + let mut trial = d.clone(); + let opts = cs::SolveOptions { keep: keep.clone(), prefer_move: Vec::new(), hold, anchors: anchors.clone() }; + solved = cs::solve(&mut trial, &opts).ok().map(|_| trial); + } + let solved = match solved { + Some(t) => Ok(t), + None => { + let mut trial = d.clone(); + cs::solve(&mut trial, &cs::SolveOptions { keep, anchors, ..Default::default() }).map(|_| trial) + } + }; + match solved { + Ok(t) => d = t, Err(e) => msg.push(format!("Constraints could not be satisfied: {e}")), } } diff --git a/crates/engine/tests/it/fix_holds_grips.rs b/crates/engine/tests/it/fix_holds_grips.rs index 9a537964c..515f2c1ef 100644 --- a/crates/engine/tests/it/fix_holds_grips.rs +++ b/crates/engine/tests/it/fix_holds_grips.rs @@ -43,3 +43,51 @@ fn dragging_the_grip_of_a_fixed_end_leaves_it_in_place() { assert!(start.near(Vec2::new(0.0, 0.0), 1e-6), "{start:?}"); assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}"); } + +#[test] +fn a_fixed_midpoint_stays_in_place_when_an_end_grip_is_dragged() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "mid" })).unwrap(); + + grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5"); + let (start, end) = ends(&s, a); + assert!(start.mid(end).near(Vec2::new(5.0, 0.0), 1e-6), "the fixed midpoint moved to {:?} ({start:?}, {end:?})", start.mid(end)); + // The dragged end lands where it was put; the line turns about its fixed midpoint. + assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}"); + assert!(start.near(Vec2::new(0.0, -5.0), 1e-6), "{start:?}"); +} + +#[test] +fn moving_an_arc_with_a_fixed_end_leaves_it_whole_and_in_place() { + let mut s = Session::new(); + let r = s.execute("arc", &json!({ "center": [0, 0], "radius": 5, "start": 0, "end": 90 })).unwrap(); + let a = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + s.execute("gcfix", &json!({ "h1": a.hex(), "p1": "end" })).unwrap(); + + s.execute("move", &json!({ "handles": [a.hex()], "delta": [1, 1] })).unwrap(); + match &s.doc().unwrap().entity(a).unwrap().kind { + EntityKind::Arc(c) => { + assert!(c.center.xy().near(Vec2::new(0.0, 0.0), 1e-6), "{:?}", c.center); + assert!((c.radius - 5.0).abs() < 1e-6, "radius {}", c.radius); + assert!((c.end - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "end at {}", c.end); + } + k => panic!("not an arc: {k:?}"), + } +} + +#[test] +fn dragging_an_end_of_one_of_two_parallel_lines_leaves_its_other_end_in_place() { + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let b = line(&mut s, [0.0, 2.0], [10.0, 2.0]); + s.execute("gcparallel", &json!({ "h1": a.hex(), "h2": b.hex() })).unwrap(); + + grip_drag(&mut s, a, Vec2::new(10.0, 0.0), "10,5"); + let (start, end) = ends(&s, a); + assert!(end.near(Vec2::new(10.0, 5.0), 1e-6), "{end:?}"); + assert!(start.near(Vec2::new(0.0, 0.0), 1e-3), "the end that was not dragged moved to {start:?}"); + // The other line took up the change. + let (b0, b1) = ends(&s, b); + assert!((b1 - b0).cross(end - start).abs() < 1e-6, "{b0:?} {b1:?}"); +} From 52aae9c1fbe092057d4e716c702506a1f6d6619d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:28 +0100 Subject: [PATCH 49/91] Fix wcmatch returning nil for patterns with many '*' wildcards (#2078) Found while testing crates/engine/src/lisp/builtins.rs:798-800 (glob_inner depth limit; depth is incremented per '*' at line 808) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/lisp/builtins.rs | 4 ++-- crates/engine/src/lisp/tests.rs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 0fcb22f03..8e89d667c 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -797,8 +797,8 @@ fn glob_inner( return Err(()); } *budget -= 1; - if depth > 64 { - return Ok(false); + if depth > 256 { + return Err(()); } let (mut t, mut p) = (0usize, 0usize); while p < pat.len() { diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 0d8e12ce0..a48c04eba 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -136,6 +136,10 @@ fn lists_and_strings() { assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T"); assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil"); assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T"); + let many = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(80), "*a".repeat(80)); + assert_eq!(ok(&mut s, &many), "T"); + let too_deep = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(400), "*a".repeat(400)); + assert!(eval(&mut s, &too_deep).is_err()); assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\""); assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2"); assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)"); From 3e6e1e534d0ff12de451482d7bfc87384901dc22 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:31 +0100 Subject: [PATCH 50/91] Constraints: an ellipse solution stored with its axes swapped or mirrored must still meet every constraint (an axis end by index does not) (#2082) --- crates/constraints/src/lib.rs | 25 ++++++++++- crates/constraints/src/model.rs | 58 +++++++++++++++++++++++- crates/constraints/tests/dof.rs | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 7ee52f0a8..72a05b6a2 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -258,7 +258,30 @@ impl Comp { return Err(SolveError::TooLarge); } let f = |x: &[f64], out: &mut Vec| self.eval(x, out, skip); - Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER)) + let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER); + // A solution the ellipses would be stored differently from (axes swapped, minor axis + // mirrored) holds only if the constraints still hold on the stored form: a constraint on + // an axis end by index does not. + if o.converged && !self.model.storable(&o.x) { + let mut out = Vec::new(); + match self.model.stored_form(&o.x) { + Some(mut xc) => { + // Contact parameters are positions along the curves: they follow the stored form. + for b in self.built.iter().filter(|b| b.form == Form::Contact) { + for (k, r) in b.refs.iter().enumerate() { + if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) { + *slot = self.model.stored_t(&o.x, r.handle, t); + } + } + } + self.eval(&xc, &mut out, skip); + o.converged = lm::max_abs(&out) <= self.tol; + o.x = xc; + } + None => o.converged = false, + } + } + Ok(o) } /// Constraint ids in conflict (each one whose removal makes the rest solvable). fn conflicts(&self, x_failed: &[f64]) -> Vec { diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index b85afd12f..165afac8d 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -32,6 +32,8 @@ pub const MAX_RIGID_POINTS: usize = 256; const MAX_RIGID_MEMBERS: usize = 100_000; /// Shared across every model built for one analysis request. pub(crate) const MAX_RIGID_WORK: usize = 1_000_000; +/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1. +const RATIO_TOL: f64 = 1e-9; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Shape { @@ -516,6 +518,57 @@ impl Model { Some(v) } + /// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the + /// longer one: a solution past that is stored with its axes swapped or its minor axis + /// mirrored (see [`Model::stored_form`]), which renumbers the axis ends. + pub fn storable(&self, x: &[f64]) -> bool { + self.slots.iter().all(|s| match s.shape { + Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL), + _ => true, + }) + } + + /// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with + /// the major axis the longer one, a full ellipse's negative ratio made positive), so the + /// constraints can be checked against what would be stored. `None` when an elliptical arc has + /// a negative ratio or an ellipse a zero one. + pub fn stored_form(&self, x: &[f64]) -> Option> { + let mut xc = x.to_vec(); + for s in &self.slots { + let Shape::Ellipse { arc } = s.shape else { continue }; + let o = s.off; + let mut ratio = *x.get(o + 4)?; + if ratio < 0.0 && !arc { + ratio = -ratio; + } + if ratio <= 0.0 { + return None; + } + if ratio > 1.0 + RATIO_TOL { + let major = p2(x, o + 2)?.perp() * ratio; + *xc.get_mut(o + 2)? = major.x; + *xc.get_mut(o + 3)? = major.y; + ratio = 1.0 / ratio; + if arc { + *xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2; + *xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2; + } + } + *xc.get_mut(o + 4)? = ratio; + } + Some(xc) + } + + /// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for + /// any other object): `-t` where a negative ratio was made positive, a quarter turn back where + /// the axes were swapped. + pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 { + let Some(s) = self.slot(h) else { return t }; + let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t }; + let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) }; + if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t } + } + /// Write solved parameters back. Returns the handles that moved. pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec { let mut moved = Vec::new(); @@ -584,8 +637,9 @@ impl Model { [a, b] if arc => (*a, *b), _ => (el.start, el.end), }; - // Keep the stored form: the major axis is the longer one. - if ratio > 1.0 { + // Keep the stored form: the major axis is the longer one. `storable` lets a + // solved ratio exceed 1 only by rounding: that is a circle, not a swap. + if ratio > 1.0 + RATIO_TOL { major = major.perp() * ratio; ratio = 1.0 / ratio; if arc { diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index ea78ca932..02fd935c0 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -382,3 +382,83 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() { assert_eq!(report.status, DofStatus::Under); assert_eq!(report.dof, Some(1000)); } + +/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused, +/// with the drawing unchanged. +fn refused_on_minor_axis_end(p: (f64, f64)) { + let mut d = Drawing::default(); + let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU); + ok(&mut d, con(K::Fix, &[s(e, Sub::Center)])); + let p = point(&mut d, p); + ok(&mut d, con(K::Fix, &[w(p)])); + let before = d.clone(); + let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default()); + assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e)); + assert_eq!(d, before); +} + +/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the +/// stored ellipse holds only by swapping its axes (and so renumbering their ends). +#[test] +fn minor_axis_end_pulled_past_the_major_axis_is_refused() { + refused_on_minor_axis_end((0.0, 6.0)); +} + +/// Pulling a minor axis end through the centre would need a negative ratio, which the stored +/// ellipse cannot hold: storing its absolute value would mirror the minor axis end. +#[test] +fn minor_axis_end_pulled_through_the_centre_is_refused() { + refused_on_minor_axis_end((0.0, -1.0)); +} + +/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes +/// swapped, which constraints that name no axis end by index do not notice. +fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle { + let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end); + ok(d, con(K::Fix, &[s(e, Sub::Center)])); + e +} + +#[test] +fn tangent_that_swaps_a_round_ellipse_axes_is_kept() { + let mut d = Drawing::default(); + let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU); + let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5)); + ok(&mut d, con(K::Fix, &[w(l)])); + ok(&mut d, con(K::Tangent, &[w(l), w(e)])); + assert!(ell(&d, e).ratio <= 1.0); + assert!(all_satisfied(&d)); +} + +#[test] +fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() { + let mut d = Drawing::default(); + let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU); + let p = point(&mut d, (0.0, 4.5)); + ok(&mut d, con(K::Fix, &[w(p)])); + ok(&mut d, con(K::Coincident, &[w(p), w(e)])); + assert!(ell(&d, e).ratio <= 1.0); + assert!(all_satisfied(&d)); +} + +#[test] +fn elliptical_arc_start_that_swaps_its_axes_is_kept() { + let mut d = Drawing::default(); + let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); + let p = point(&mut d, (0.0, 4.5)); + ok(&mut d, con(K::Fix, &[w(p)])); + ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)])); + assert!(ell(&d, e).ratio <= 1.0); + assert!(all_satisfied(&d)); +} + +#[test] +fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() { + let mut d = Drawing::default(); + let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU); + let c = circle(&mut d, (0.0, 6.0), 1.5); + ok(&mut d, con(K::Fix, &[w(c)])); + ok(&mut d, con(K::Tangent, &[w(c), w(e)])); + assert!(ell(&d, e).ratio <= 1.0); + assert!(all_satisfied(&d)); +} From 0f60269868463ddba512aada95561786b56cc8f6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:35 +0100 Subject: [PATCH 51/91] MCP: resend once when the app refuses a token read before --launch opened it (fixes #2087) (#2098) --- crates/mcp/src/backend.rs | 137 +++++++++++++++++++++++++++++++++----- 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 693839335..9d012076a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -184,17 +184,20 @@ fn is_read_only(method: &str) -> bool { ) } -impl Backend for Remote { - /// Send a control request with the token when available, refreshing it before a safe retry. - /// A mutation that may have reached the server is never resent automatically. - fn call(&mut self, method: &str, params: Value) -> Result { - let id = self.next_id; - self.next_id += 1; +impl Remote { + /// Put the current token (if any) on `req`, replacing the one it carries. + fn set_token(&mut self, req: &mut Value) { let token = self.token().map(str::to_string); - let mut req = json!({"id": id, "method": method, "params": params}); - if let (Some(obj), Some(token)) = (req.as_object_mut(), token) { - obj.insert("token".into(), Value::String(token)); + if let Some(obj) = req.as_object_mut() { + obj.remove("token"); + if let Some(token) = token { + obj.insert("token".into(), Value::String(token)); + } } + } + /// Send `req` and parse the reply, refreshing the token before a safe retry after a transport + /// error. A mutation that may have reached the server is never resent automatically. + fn exchange(&mut self, method: &str, req: &mut Value) -> Result { let reply = match self.roundtrip(&req.to_string()) { Ok(r) => r, Err((e, sent)) => { @@ -208,12 +211,7 @@ impl Backend for Remote { )); } // A restarted server may have rotated its token before dropping the old socket. - if let Some(obj) = req.as_object_mut() { - obj.remove("token"); - if let Some(token) = self.token() { - obj.insert("token".into(), Value::String(token.to_owned())); - } - } + self.set_token(req); self.roundtrip(&req.to_string()).map_err(|(e, _)| { self.token = None; self.conn = None; @@ -221,12 +219,37 @@ impl Backend for Remote { })? } }; - let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?; + serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}")) + } +} + +/// An `unauthorized` reply (the app also closes the connection). +fn unauthorized(v: &Value) -> bool { + v.get("error").and_then(Value::as_str).is_some_and(|e| e.starts_with("unauthorized")) +} + +impl Backend for Remote { + /// Send a control request with the token when available. A refused token is read again and the + /// request resent once, only when the app says it did not run it. + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let mut req = json!({"id": id, "method": method, "params": params}); + self.set_token(&mut req); + let mut v = self.exchange(method, &mut req)?; + // The token is read before `--launch` opens the app, which then writes a new one (#2087); the + // file can also be left over from an earlier session. Once, so a wrong token can't loop. + if unauthorized(&v) && v.get("state").and_then(Value::as_str) == Some("not-run") { + self.token = None; + self.conn = None; + self.set_token(&mut req); + v = self.exchange(method, &mut req)?; + } if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { let e = v.get("error").and_then(Value::as_str).unwrap_or("error"); - if e.starts_with("unauthorized") { + if unauthorized(&v) { self.token = None; self.conn = None; } @@ -405,6 +428,7 @@ impl Backend for Headless { mod tests { use super::*; use std::net::TcpListener; + use std::sync::Arc; /// A server that reads one request per connection and closes without replying must not see the request twice. #[test] @@ -479,6 +503,85 @@ mod tests { assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong")); server.join().unwrap(); } + /// Answer one request per connection like the app's control server: `reply` sees the request + /// and the connection number and says what to send back; the server closes after each reply. + /// Returns how many requests arrived, waiting a short while for each next connection. + fn fake_app(listener: TcpListener, reply: impl Fn(&Value, usize) -> Value + Send + 'static) -> std::thread::JoinHandle { + std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let mut seen = 0; + loop { + let start = std::time::Instant::now(); + let conn = loop { + match listener.accept() { + Ok((conn, _)) => break Some(conn), + Err(_) if start.elapsed() < Duration::from_millis(500) => std::thread::sleep(Duration::from_millis(10)), + Err(_) => break None, + } + }; + let Some(conn) = conn else { return seen }; + conn.set_nonblocking(false).unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + let mut out = conn; + writeln!(out, "{}", reply(&serde_json::from_str(&line).unwrap(), seen)).unwrap(); + seen += 1; + } + }) + } + + /// `--launch` reads the token before it opens the app, so the first request carries the token + /// of an earlier session (or none); the app refuses it without running it, and the call is + /// resent once with the token the app has just written (#2087). + #[test] + fn unauthorized_not_run_is_resent_once_with_the_new_token() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let old = crate::control_token::issue(addr.port()).unwrap(); + let fresh = Arc::new(std::sync::Mutex::new(String::new())); + let f = fresh.clone(); + let server = fake_app(listener, move |req, n| { + if n == 0 { + assert_eq!(req["token"].as_str(), Some(old.as_str())); + // The app starts and writes its own session's token. + *f.lock().unwrap() = crate::control_token::issue(addr.port()).unwrap(); + json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"}) + } else if req["token"].as_str() == Some(f.lock().unwrap().as_str()) { + json!({"ok": true, "result": "drawing"}) + } else { + json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"}) + } + }); + let mut remote = Remote::lazy(&addr.to_string(), false); + let first = remote.call("engine.execute", json!({"command": "circle", "params": {}})); + let requests = server.join().unwrap(); + crate::control_token::revoke(addr.port()); + assert_eq!(first, Ok(json!("drawing"))); + assert_eq!(requests, 2); + assert_eq!(remote.token.as_deref(), Some(fresh.lock().unwrap().as_str())); + } + + /// A token the app keeps refusing is resent once, not in a loop, and a refusal that doesn't + /// say the request never ran is not resent at all. + #[test] + fn unauthorized_is_resent_at_most_once_and_only_when_not_run() { + for (state, connections) in [(Some("not-run"), 2), (Some("may-have-run"), 1), (None, 1)] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = fake_app(listener, move |_, _| { + let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token"}); + if let (Some(o), Some(state)) = (r.as_object_mut(), state) { + o.insert("state".into(), json!(state)); + } + r + }); + let mut remote = Remote::lazy(&addr, false); + let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err(); + assert!(err.starts_with("unauthorized"), "{err}"); + assert_eq!(server.join().unwrap(), connections, "{state:?}"); + assert!(remote.token.is_none() && remote.conn.is_none()); + } + } /// A failed mutation write reports uncertain delivery and does not open a retry connection. #[test] fn failed_mutation_write_is_not_resent() { From 31de470926d8cf100a20534ec0c0915642fb79b8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:39 +0100 Subject: [PATCH 52/91] Fall back to decimal for huge engineering-unit distances (#2114) Found while testing crates/engine/src/units.rs:18-27 (format_distance, LUNITS 3 branch) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/units.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 777e6477c..a3018d8f3 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -22,6 +22,9 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String { let rounded = (v.abs() * scale).round() / scale; let a = if rounded.is_finite() { rounded } else { v.abs() }; let ft = (a / 12.0).floor(); + if ft >= I64_SAFE_MAX { + return format!("{:.*}", prec, v); + } let inch = a - ft * 12.0; format!("{}{}'-{:.*}\"", if neg { "-" } else { "" }, ft as i64, prec, inch) } @@ -360,6 +363,12 @@ pub const DEG: f64 = PI / 180.0; mod tests { use super::*; + #[test] + fn engineering_huge_distance_falls_back_to_decimal() { + assert_eq!(format_distance(1e30, 3, 2), format!("{:.2}", 1e30)); + assert_eq!(format_distance(-1e300, 3, 8), format!("{:.8}", -1e300)); + } + #[test] fn mixed_distance_needs_a_positive_fraction_after_the_whole_number() { assert_eq!(parse_distance("6 1/2"), Some(6.5)); From 0c15f7106c091b00ed4cdb08cb2f6b68cdd8b090 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Sun, 25 Oct 2026 17:13:42 +0100 Subject: [PATCH 53/91] i18n(sv): translate 49 new strings (#2120) Brings the Swedish catalog up to date with upstream main (e86d895). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sv.tsv | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5a2596325..03d51d535 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1397,3 +1397,52 @@ menu Modify Ändra Window < Fönster < X offset (mm) X-förskjutning (mm) Y offset (mm) Y-förskjutning (mm) + AutoLISP (LSP) AutoLISP (LSP) + Units of new drawings Enheter för nya ritningar + Imperial (inches) Anglosaxiskt (tum) + Metric (millimetres) Metriskt (millimeter) + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Nya ritningar och den första ritningen vid start använder dessa enheter om ingen standardmall är angiven (QNEWTEMPLATE). + Analyze Usage Analysera användning + Implied Loops Implicita slingor + Angstroms Ångström + Architectural Arkitektonisk + Astronomical Units Astronomiska enheter + Centimeters Centimeter + Decameters Dekameter + Decimal Decimal + Decimal Degrees Decimalgrader + Decimeters Decimeter + Deg/Min/Sec Grad/min/sek + Don't Save Spara inte + Drawing Units Ritningsenheter + Engineering Teknisk + Feet Fot + Fractional Bråk + Gigameters Gigameter + Grads Gon + Hectometers Hektometer + Inches Tum + Insertion scale Insättningsskala + Kilometers Kilometer + Light Years Ljusår + Meters Meter + Microinches Mikrotum + Microns Mikrometer + Miles Engelska mil + Millimeters Millimeter + Mils Tusendels tum + Nanometers Nanometer + Parsecs Parsek + Radians Radianer + Sample output Exempel på utdata + Save changes to {name}? Spara ändringar i {name}? + Scientific Vetenskaplig + Surveyor's Units Lantmäterienheter + Unitless Enhetslös + Units to scale inserted content Enheter för skalning av infogat innehåll + US Survey Feet Lantmäterifot (USA) + US Survey Inch Lantmäteritum (USA) + US Survey Mile Engelsk lantmäterimil (USA) + US Survey Yard Lantmäteriyard (USA) + Yards Yard + Your changes will be lost if you don't save them. Dina ändringar går förlorade om du inte sparar dem. From 422d42411a6004ce9a96825d117a7e914255b127 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 10:13:45 -0600 Subject: [PATCH 54/91] fix: omit empty outlines in complex text shaping (#1960) * fix: filter empty outlines in complex shaping * docs: describe complex shaping and its empty-glyph regression --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- crates/fonts/src/complex.rs | 55 ++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index 0f13b9bd3..a8dd367c3 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -108,7 +108,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 for p in contours.iter_mut().flatten() { *p += Vec2::new(dy * shear, dy); } - out.glyphs.push(contours); + out.push_glyph(contours, None); } let adv = g.advance * k; let (u, o) = flags(g.cluster); @@ -220,6 +220,59 @@ fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec mod tests { use super::*; + /// A single empty glyph with a positive advance, without relying on installed fonts. + fn empty_glyph_face() -> Face { + let mut head = vec![0_u8; 54]; + head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000_u16.to_be_bytes()); + let mut hhea = vec![0_u8; 36]; + hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes()); + hhea[4..6].copy_from_slice(&800_i16.to_be_bytes()); + hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes()); + hhea[34..36].copy_from_slice(&1_u16.to_be_bytes()); + let tables = [ + (*b"head", head), + (*b"hhea", hhea), + (*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0. + (*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph. + ]; + let mut bytes = vec![0_u8; 12 + tables.len() * 16]; + bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes()); + bytes[4..6].copy_from_slice(&4_u16.to_be_bytes()); + bytes[6..8].copy_from_slice(&64_u16.to_be_bytes()); + bytes[8..10].copy_from_slice(&2_u16.to_be_bytes()); + for (i, (tag, data)) in tables.into_iter().enumerate() { + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } + let record = 12 + i * 16; + let offset = u32::try_from(bytes.len()).unwrap(); + let len = u32::try_from(data.len()).unwrap(); + bytes[record..record + 4].copy_from_slice(&tag); + bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes()); + bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes()); + bytes.extend(data); + } + Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses") + } + + /// Empty complex glyphs retain spacing and underlines without adding geometry or text slots. + #[test] + fn empty_complex_glyph_keeps_advance_without_geometry() { + let face = empty_glyph_face(); + let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph"); + assert!(shaped.width > 0.0, "the font's advance is retained"); + assert!(shaped.is_empty()); + assert!(shaped.glyphs.is_empty()); + assert!(shaped.text.is_empty()); + + let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph"); + assert_eq!(underlined.width, shaped.width); + assert!(underlined.glyphs.is_empty()); + assert!(underlined.text.is_empty()); + assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline"); + } + #[test] fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() { // With or without installed fonts: the Persian words are fallback glyphs or From 8f7421737ddaede4a3a80fab8ce73aeadb5af701 Mon Sep 17 00:00:00 2001 From: Jonas <80707953+jonascloud@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:13:49 +0100 Subject: [PATCH 55/91] fix(ui): keep layout tabs stable when toggling Tool Sets (#2008) Co-authored-by: cloudj --- crates/ui-egui/src/chrome.rs | 5 +- crates/ui-egui/tests/it/chrome_geometry.rs | 54 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 893379f35..0e79f8444 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -298,7 +298,8 @@ fn visible_tabs(widths: &[f32], active: Option, room: f32) -> std::ops::R start..active + 1 } -/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right. +/// Status bar: layout tabs follow the left-hand layout actions, independent of palette docking. +/// Coordinates and drafting toggles remain on the right. pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::bottom("cc_status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { @@ -328,7 +329,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { } x += 22.0; } - x = x.max(r.left() + if app.ui.show_toolsets { 232.0 } else { 60.0 }); + x += 8.0; // Right side: toggles, right-aligned. let s = app.session.settings.clone(); let anno_all = app.session.doc().is_ok_and(|d| d.anno_all_visible()); diff --git a/crates/ui-egui/tests/it/chrome_geometry.rs b/crates/ui-egui/tests/it/chrome_geometry.rs index ab9c5b8fa..320cabf37 100644 --- a/crates/ui-egui/tests/it/chrome_geometry.rs +++ b/crates/ui-egui/tests/it/chrome_geometry.rs @@ -1,4 +1,5 @@ -//! The real app's painted geometry at native and compact window sizes. +//! The real app's painted geometry at native and compact window sizes. Layout tabs stay anchored +//! to the status bar independently of palette visibility and docking, and remain interactive. use cadcraft_engine::Session; use cadcraft_geom::Vec2; @@ -83,6 +84,57 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() { assert!(overlaps.is_empty(), "{}", overlaps.join("\n")); } +#[test] +fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() { + for scale in [1.0, 2.0] { + let mut h = harness(1200.0, 760.0, scale, false); + let tab_rects = + |h: &Harness<'_, CadApp>| ["Model", "Layout1", "Layout2"].map(|name| h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect()); + let original = tab_rects(&h); + for (command, params) in [ + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.dock", serde_json::json!({"operation":"resizeSplit", "path":[], "size":{"FixedFirst":360.0}})), + ("ui.dock", serde_json::json!({"operation":"float", "panel":"toolSets", "rect":[400,120,260,480]})), + ("ui.dock", serde_json::json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"right"})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.toggle.toolsets", serde_json::json!({})), + ("ui.dock.reset", serde_json::json!({})), + ] { + h.state_mut().run(command, params.clone()).unwrap(); + h.run_steps(5); + assert_eq!(tab_rects(&h), original, "{command} {params} at scale {scale} must not move the tabs"); + } + // A fixed but oversized blank reservation would stop the jump without fixing the layout. + let gap = original[0].left() - h.get_by_label("Layout list").rect().right(); + assert!((0.0..=16.0).contains(&gap), "tabs must follow the layout actions, not reserve palette space: {gap}"); + } +} + +#[test] +fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() { + for visible in [true, false] { + let mut h = harness(1400.0, 760.0, 1.0, false); + h.state_mut().run("ui.toggle.toolsets", serde_json::json!({"on":visible})).unwrap(); + h.run_steps(5); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout1").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout1".into())); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Model").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Model); + // There are two add buttons; exercise the one between Model and the paper-layout tabs. + let model = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Model").rect(); + h.get_all_by_label("New layout").find(|node| node.rect().left() > model.right()).unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout3".into())); + assert_eq!(h.state().session.doc().unwrap().layouts.len(), 3); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout2").click(); + h.run_steps(3); + assert_eq!(h.state().session.state().unwrap().space, cadcraft_engine::doc::Space::Paper("Layout2".into())); + } +} + #[test] fn compact_toolbar_overflow_keeps_help_reachable() { let mut h = harness(900.0, 560.0, 1.0, false); From 5acec7d0be686899f26a32e10532c9c2bdecce39 Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Sun, 25 Oct 2026 09:13:53 -0700 Subject: [PATCH 56/91] PLOTWINDOW: pick both corners even with ORTHO on (#1893) * PLOTWINDOW: pick both corners even with ORTHO on PLOTWINDOW's opposite-corner prompt used Prompt::base, so the canvas bent the picked point onto the nearest axis with ORTHOMODE on: the second corner landed above/beside the first, the window had no width or height, and set_plot_window refused it. The prompt now uses Prompt::corner, the flag ZOOM Window, RECTANG and ARRAYRECT already use to keep a rectangle's opposite corner free of ortho. The first corner sets the base as before, so the rubber band and direct distance entry still work. Regression test drives the interactive machine through snap_cursor with ORTHOMODE on, picks two non-axis-aligned corners and checks the stored window keeps both extents; an ordinary LINE prompt still follows ortho. Fixes #1641 * docs: document the functions touched by #1641 --- crates/engine/src/cmd/plotters.rs | 2 ++ crates/engine/src/prompt.rs | 4 +-- crates/engine/tests/plotters.rs | 49 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 762f65066..a3e1b59f1 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -362,6 +362,8 @@ impl Interactive for PlotWindowM { fn name(&self) -> &'static str { "PLOTWINDOW" } + /// Ask for the first corner, then for the opposite one marked as a rectangle corner + /// (`Prompt::corner`), so ORTHO does not bend the pick and the window keeps its area. fn prompt(&self, _s: &Session) -> Prompt { match self.first { None => Prompt::new("Specify first corner", Accept::POINT), diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 63883240c..c02551b80 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -41,8 +41,8 @@ pub struct Prompt { /// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred /// pick arrives as the plain point it was picked at. pub deferred: bool, - /// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho - /// does not bend the opposite corner, so the rectangle keeps its area. + /// The base is a rectangle's first corner (ZOOM Window, PLOTWINDOW, RECTANG, ARRAYRECT's unit + /// cell): ortho does not bend the opposite corner, so the rectangle keeps its area. #[serde(skip_serializing_if = "std::ops::Not::not")] pub corner: bool, /// The grip this prompt stretches (one hot grip at the STRETCH prompt), as (object, grip diff --git a/crates/engine/tests/plotters.rs b/crates/engine/tests/plotters.rs index 43268d38a..81aea1221 100644 --- a/crates/engine/tests/plotters.rs +++ b/crates/engine/tests/plotters.rs @@ -6,6 +6,8 @@ use cadcraft_doc::{Drawing, Space}; use cadcraft_engine::Session; use cadcraft_engine::cmd::file::{IoHooks, base64_decode, set_io}; use cadcraft_engine::cmd::plotters::{PlotterStore, set_plotter_store}; +use cadcraft_engine::snap::tracking::CursorQuery; +use cadcraft_engine::{Input, geom::Vec2}; use serde_json::{Value, json}; /// The plot hook echoes the options it gets. @@ -13,6 +15,7 @@ fn echo_plot(_: &Drawing, _: &Space, opts: &Value) -> Result, String> { Ok(opts.to_string().into_bytes()) } +/// A session with in-memory plotters and I/O hooks, holding one line so layouts have content. fn session() -> Session { set_plotter_store(PlotterStore::memory()); set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Err("no writer in this test".into()), plot: Some(echo_plot) }); @@ -22,6 +25,7 @@ fn session() -> Session { s } +/// The device names the PLOTTERMANAGER report lists, in the order it lists them. fn names(s: &mut Session) -> Vec { let r = s.execute("plottermanager", &json!({})).unwrap(); r["devices"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap().to_string()).collect() @@ -34,6 +38,8 @@ fn plotted(r: Value) -> Value { } #[test] +/// PLOTTERMANAGER creates, edits, saves, loads and deletes user devices; built-ins are fixed, +/// duplicate names and papers with margins larger than the page are refused. fn manage_devices() { let mut s = session(); let n = names(&mut s); @@ -73,6 +79,8 @@ fn manage_devices() { } #[test] +/// PAGESETUP copies the device's papers and margins into the layout's page, keeps a paper the new +/// device also has, and falls back to the device default when it does not. fn page_setup_takes_a_device_and_its_papers() { let mut s = session(); s.execute("plottermanager", &json!({"action": "new", "name": "Plan Printer", "output": "pdf"})).unwrap(); @@ -110,6 +118,8 @@ fn page_setup_takes_a_device_and_its_papers() { } #[test] +/// PLOT and EXPORTPDF hand the hook the device's output, resolution, paper and margins, and a +/// layout plots with its own page setup; unknown devices and papers are refused. fn plot_uses_the_device() { let mut s = session(); s.execute("plottermanager", &json!({"action": "new", "name": "Sketch PNG", "output": "png", "resolution": 96})).unwrap(); @@ -136,6 +146,7 @@ fn plot_uses_the_device() { } #[test] +/// The typed -PLOTTERMANAGER prompt machine creates a device, adds a paper to it and deletes it. fn typed_plotter_manager() { let mut s = session(); for line in ["-plottermanager", "New", "Typed Plotter", "Svg"] { @@ -155,6 +166,8 @@ fn typed_plotter_manager() { } #[test] +/// Hostile names and values (empty or path-like names, unknown output, huge resolution, negative +/// or enormous paper sizes, oversized loads) are refused instead of panicking. fn hostile_names_and_values_are_refused() { let mut s = session(); let r = s.execute("plottermanager", &json!({"action": "new", "name": "../../etc/passwd"})).unwrap(); @@ -174,6 +187,8 @@ fn hostile_names_and_values_are_refused() { } #[test] +/// PLOTWINDOW keeps both picked corners for the plot dialog, previews the rubber-band rectangle +/// after the first pick, and refuses a window with no area (JSON form included). fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() { let mut s = Session::new(); // Typed: two corners, with the window's rectangle following the cursor after the first. @@ -190,3 +205,37 @@ fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() { assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]])); assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err()); } + +/// Snap `raw` at the current prompt the way the canvas does, so a test sees the pick ORTHO and +/// object snap would really produce. +fn hover(s: &mut Session, raw: Vec2) -> Vec2 { + let p = s.current_prompt().expect("a prompt"); + let q = CursorQuery { base: p.base, deferred: p.deferred, corner: p.corner, aperture: 1e-6, tol: 1e-6, now: 0.0, shift: false }; + s.snap_cursor(raw, &q).point +} + +#[test] +/// Regression (#1641): with ORTHO on, PLOTWINDOW's opposite corner stays where the cursor is so +/// the window keeps both extents, while an ordinary point prompt still follows ortho. +fn plotwindow_picks_both_corners_with_ortho_on() { + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "OSMODE", "value": 0})).unwrap(); + s.execute("setvar", &json!({"name": "ORTHOMODE", "value": 1})).unwrap(); + s.cmdline("plotwindow").unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify first corner"); + let a = hover(&mut s, Vec2::new(10.0, 20.0)); + s.input(Input::Point(a)).unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner"); + let c = hover(&mut s, Vec2::new(40.0, 5.0)); + assert!(c.near(Vec2::new(40.0, 5.0), 1e-9), "the opposite corner is where the cursor is: {c:?}"); + s.input(Input::Point(c)).unwrap(); + assert!(s.running.is_none()); + let w = s.plot_window.expect("the picked window"); + assert!(w.width() > 0.0 && w.height() > 0.0, "the window has both extents: {w:?}"); + assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0)); + + // An ordinary point prompt still follows ortho: a line's next point stays on its axis. + s.cmdline("line 0,0").unwrap(); + let c = hover(&mut s, Vec2::new(10.0, 4.0)); + assert!(c.near(Vec2::new(10.0, 0.0), 1e-9), "ortho keeps the line horizontal: {c:?}"); +} From 63a5569970afd4dc037cf7c0f44556c90ef3a73a Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Sun, 25 Oct 2026 09:13:56 -0700 Subject: [PATCH 57/91] In-place text editing accepts IME composition and commit (#1905) * ui: accept IME composition and commit in the command line (#1761) The command line is a custom buffer with keyboard focus staying on the drawing, so no egui TextEdit ever owned the IME: egui never published an IME output, winit never called set_ime_allowed(true), and a Windows IME (Microsoft Pinyin, Japanese, Korean) could not even switch on. Even when IME events arrived they were ignored, so in-place single-line TEXT edits (and every other text prompt) accepted ASCII only. - Publish an IME output (command-line rect and caret) while a prompt asks for text, enabling the OS IME; release it when the prompt no longer collects text, so ASCII typing and shortcuts keep their behaviour. - Handle Event::Ime: Preedit is drawn as an underlined composition after the buffer but never saved; Commit inserts the committed string once. Newline/Return commits are ignored like egui's own text fields, and Enter/Escape/submit clear any composition. Tests: a headless command-line test asserts the IME is enabled for a TEXTEDIT prompt, the preedit stays out of the buffer, the commit lands exactly once and the text entity changes; a kittest double-click test edits TEXT in place with IME preedit and commit. * cmdline IME: build the IMEOutput with egui 0.37's constructor and a context generation Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/cmdline.rs | 109 ++++++++++++++++++- crates/ui-egui/tests/it/text_double_click.rs | 21 +++- docs/localization-parity.md | 12 +- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index e9e0db8c7..d78561551 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -33,6 +33,16 @@ pub struct CmdLine { pub option_rows: Vec<(Rect, String)>, /// Where the Dynamic Input tooltip stays while the options menu is open. pub options_anchor: Option, + /// The IME's composition (pre-edit) text: shown after the buffer while composing, never saved. + pub preedit: String, + /// The command line's editing rectangle and caret (screen) from the last frame, for the IME's + /// composition window (see `enable_ime`). + pub ime_rect: Option<(Rect, Rect)>, + /// The command line's IME context generation: a new one each time it starts collecting text, + /// as egui asks of an IME owner that (re)gains focus. + ime_generation: u64, + /// Whether the command line published an IME context last frame. + ime_on: bool, } /// The most history lines the command line shows above its bar. @@ -247,9 +257,36 @@ pub struct KeyPress { pub plain: bool, } +/// Let the operating system's IME compose into the command line while a prompt asks for text. +/// Nothing owns keyboard focus there (typing goes to the command line from anywhere), so egui +/// never enables the IME by itself: publishing an IME output does it, with the command line's +/// rectangle and caret for the composition window. Without this a Windows IME (Pinyin, Japanese, +/// Korean...) cannot even switch on (#1761). +fn enable_ime(app: &mut CadApp, ctx: &egui::Context) { + if !app.cmd.ime_on { + app.cmd.ime_on = true; + app.cmd.ime_generation = app.cmd.ime_generation.wrapping_add(1); + } + let (rect, cursor_rect) = app.cmd.ime_rect.unwrap_or_else(|| { + let r = ctx.content_rect(); + (r, Rect::from_min_size(pos2(r.left() + 30.0, r.bottom() - 34.0), vec2(1.0, 18.0))) + }); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput::new( + egui::Id::unique("cc_cmdline_ime"), + app.cmd.ime_generation, + egui::IMEPurpose::Normal, + rect, + cursor_rect, + )); + }); +} + /// Handle keyboard input destined for the command line (when no text field has focus). pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { if ctx.egui_wants_keyboard_input() { + app.cmd.preedit.clear(); + app.cmd.ime_on = false; return; } let events = ctx.input(|i| i.events.clone()); @@ -257,6 +294,13 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { app.cmd.watch_selection(&selection); let mac = ctx.os() == egui::os::OperatingSystem::Mac; let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number); + if text_prompt { + enable_ime(app, ctx); + } else { + // No prompt is collecting text: a composition left over from one cannot belong here. + app.cmd.preedit.clear(); + app.cmd.ime_on = false; + } options_still_fit(app); for ev in events { // The options menu takes the arrows, Enter and Escape; typing closes it. @@ -297,13 +341,26 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } else if t == " " && !text_prompt { submit(app); } else { + app.cmd.preedit.clear(); app.cmd.buffer.push_str(&t); } } + // IME composition: the pre-edit is shown but stays out of the buffer; the commit is + // the only text that is inserted. Enter/Return commits arrive separately as keys, so + // a newline commit is ignored (as egui's own text fields do) instead of submitting. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.cmd.preedit = text, + egui::Event::Ime(egui::ImeEvent::Commit(text)) => { + app.cmd.preedit.clear(); + if text != "\n" && text != "\r" { + app.cmd.buffer.push_str(&text); + } + } + egui::Event::Ime(_) => {} egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => match key { Key::Enter => submit(app), Key::Escape => { app.cmd.buffer.clear(); + app.cmd.preedit.clear(); app.session.cancel(); } Key::Backspace => { @@ -357,6 +414,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } pub fn submit(app: &mut CadApp) { + app.cmd.preedit.clear(); // Grip dimensional input: the typed lengths and angles place the grip. if crate::grip_dyn::submit(app) { return; @@ -493,14 +551,26 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } } } - let g = p.layout_no_wrap(app.cmd.buffer.clone(), font, t.text); + let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + let mut caret_x = x + gw; + // The IME's composition text sits after the buffer (underlined) until it is committed. + if !app.cmd.preedit.is_empty() { + let pg = p.layout_no_wrap(app.cmd.preedit.clone(), font, t.text); + let pw = pg.size().x; + p.galley(pos2(caret_x, bar.center().y - pg.size().y / 2.0), pg, t.text); + p.line_segment([pos2(caret_x, bar.bottom() - 7.0), pos2(caret_x + pw, bar.bottom() - 7.0)], Stroke::new(1.0, t.accent)); + caret_x += pw; + } // Caret. let blink = (ui.input(|i| i.time) * 2.0).floor() as i64 % 2 == 0; if blink { - p.line_segment([pos2(x + gw + 1.0, bar.top() + 5.0), pos2(x + gw + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text)); + p.line_segment([pos2(caret_x + 1.0, bar.top() + 5.0), pos2(caret_x + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text)); } + // Where the IME's composition window follows: the whole command line and the caret in it. + let caret = Rect::from_min_size(pos2(caret_x + 1.0, bar.top() + 5.0), vec2(1.0, h - 10.0)); + app.cmd.ime_rect = Some((bar, caret)); // History chevron. crate::icons::paint( &p, @@ -668,19 +738,52 @@ mod tests { assert_eq!(app.ui.history_lines, 12); } - fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); app.ui(ui); }); out.textures_delta.clear(); + out } fn press(app: &mut CadApp, ctx: &egui::Context, at: Pos2, pressed: bool) { frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]); } + #[test] + fn ime_preedit_is_shown_and_its_commit_is_inserted_once() { + use cadcraft_engine::geom::Vec2; + let mut app = CadApp::new(Session::new(), crate::Services::default()); + app.run("text", serde_json::json!({"at": [0, 0], "text": "HELLO", "height": 2.5})).unwrap(); + app.start("textedit"); + app.session.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap(); + assert_eq!(app.session.current_prompt().map(|p| p.message.clone()).as_deref(), Some("Enter new text")); + let ctx = egui::Context::default(); + let out = frame(&mut app, &ctx, Vec::new()); + // The IME is enabled for the command line while it is collecting text. + assert!(out.platform_output.ime.is_some()); + // Composition is shown at the command line but is not part of the text being edited. + frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })]); + assert_eq!(app.cmd.preedit, "zhong"); + assert!(app.cmd.buffer.is_empty()); + // The committed text replaces the pre-edit, exactly once. + frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("中文".into()))]); + assert!(app.cmd.preedit.is_empty()); + assert_eq!(app.cmd.buffer, "中文"); + let enter = |pressed| Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![enter(true), enter(false)]); + let text = app.session.doc().unwrap().model.iter().next().map(|e| match &e.kind { + cadcraft_engine::doc::EntityKind::Text(t) => t.value.clone(), + _ => String::new(), + }); + assert_eq!(text.as_deref(), Some("中文")); + // Back to selecting objects: the next frame lets the IME go, so it can't swallow ASCII typing. + let out = frame(&mut app, &ctx, Vec::new()); + assert!(out.platform_output.ime.is_none()); + } + #[test] fn history_expands_by_click_chevron_f2_and_resizes_by_drag_without_touching_the_drawing() { let mut app = CadApp::new(Session::empty(), crate::Services::default()); diff --git a/crates/ui-egui/tests/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs index 782234d10..5cedfbf61 100644 --- a/crates/ui-egui/tests/it/text_double_click.rs +++ b/crates/ui-egui/tests/it/text_double_click.rs @@ -4,7 +4,7 @@ use cadcraft_engine::doc::EntityKind; use cadcraft_engine::geom::Vec2; use cadcraft_engine::{Input, Session}; use cadcraft_ui_egui::{CadApp, Services}; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, ImeEvent, Key, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; use serde_json::json; @@ -68,6 +68,25 @@ fn double_click_on_text_starts_textedit_for_that_object() { } } +#[test] +fn double_clicked_text_takes_ime_composition_and_commit() { + let mut h = harness(); + double_click(&mut h, Vec2::new(4.0, 1.2)); + assert_eq!(h.state().session.current_prompt().unwrap().message, "Enter new text"); + // Composition is shown, not saved; the commit is inserted exactly once (#1761). + h.event(Event::Ime(ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })); + h.run_steps(1); + assert_eq!(h.state().cmd.preedit, "zhong"); + assert!(h.state().cmd.buffer.is_empty()); + h.event(Event::Ime(ImeEvent::Commit("中文".into()))); + h.run_steps(1); + assert!(h.state().cmd.preedit.is_empty()); + assert_eq!(h.state().cmd.buffer, "中文"); + h.key_press(Key::Enter); + h.run_steps(1); + assert_eq!(text_at(&h, 0), "中文"); +} + #[test] fn double_click_elsewhere_starts_no_command() { for at in [Vec2::new(30.0, 5.0), Vec2::new(15.0, 30.0)] { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 4f4964a41..b4ff05f68 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (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-22 · **Change:** 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). @@ -37,9 +37,12 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. 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. -- **Text input:** egui/winit IME. 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. +- **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. ## Languages @@ -86,6 +89,7 @@ each language, which needs humans. Translations parallelise fully once the catal | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-14 | minor | Swedish completed: 95 rows for strings added since (Hatch and Gradient dialog, Block Editor, REFEDIT, multileader editing, Options selection settings, MTEXT editor, recent files); 1,330 rows, every key of the Ukrainian reference catalog | From 1c85d339c3e2b0f953ce0f32c4ef8688bc39cd72 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:15:42 +0100 Subject: [PATCH 58/91] Reject negative or out-of-range minutes/seconds in D'" angles (#2116) Found while testing crates/engine/src/units.rs:333-340 (parse_dms) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/units.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index a3018d8f3..0a18a0039 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -327,6 +327,12 @@ fn bearing(t: &str) -> Option { Some(deg.to_radians()) } +/// Minutes or seconds of a D'" angle: a number in `0..=60`. +fn dms_part(t: &str) -> Option { + let v: f64 = t.trim().parse().ok()?; + (0.0..=60.0).contains(&v).then_some(v) +} + /// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle). fn parse_dms(t: &str) -> Option { let (neg, t) = match t.trim().strip_prefix('-') { @@ -339,11 +345,11 @@ fn parse_dms(t: &str) -> Option { if !rest.is_empty() { let (m, rest2) = rest.split_once('\'').unwrap_or((rest, "")); if !m.trim().is_empty() { - deg += m.trim().parse::().ok()? / 60.0; + deg += dms_part(m)? / 60.0; } let s = rest2.trim().trim_end_matches('"'); if !s.is_empty() { - deg += s.parse::().ok()? / 3600.0; + deg += dms_part(s)? / 3600.0; } } let deg = if neg { -deg } else { deg }; @@ -363,6 +369,14 @@ pub const DEG: f64 = PI / 180.0; mod tests { use super::*; + #[test] + fn dms_minutes_and_seconds_must_be_within_0_to_60() { + for t in ["45d-30'", "45d30'-15\"", "45d75'", "45d30'75\"", "n45d-30'e"] { + assert_eq!(parse_angle(t), None, "{t}"); + } + assert!(parse_angle("45d30'15\"").is_some()); + } + #[test] fn engineering_huge_distance_falls_back_to_decimal() { assert_eq!(format_distance(1e30, 3, 2), format!("{:.2}", 1e30)); From cddf09beb2818e3157d51c7c9497df755d99beca Mon Sep 17 00:00:00 2001 From: asimfali Date: Sun, 25 Oct 2026 19:25:21 +0300 Subject: [PATCH 59/91] DXF/DWG: read the drawing's WIPEOUTFRAME from its WIPEOUTVARIABLES object (#908) AutoCAD stores the wipeout frame setting in the drawing's WIPEOUTVARIABLES object (group 70: 0 = frames hidden). CADCraft ignored it and always showed the frames (the default of 1), so masks such as the diamond inside a numbered circle were outlined. The flag is now read into the WIPEOUTFRAME setting. Co-authored-by: simanov Co-authored-by: Claude Sonnet 5.5 --- crates/io/src/dxf_read.rs | 6 ++++++ crates/io/tests/wipeout_frame.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 crates/io/tests/wipeout_frame.rs diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 6a3ea222d..e71f7725f 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1984,6 +1984,8 @@ struct Objects { layout_views: Vec<(String, (Vec2, f64))>, /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). sortents: Vec<(String, HashMap)>, + /// WIPEOUTVARIABLES: the drawing's WIPEOUTFRAME (0 = frames hidden, 1 = shown). + wipeout_frame: Option, } const MAX_OBJECTS: usize = 1_000_000; @@ -2022,6 +2024,7 @@ impl Objects { "DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()), "MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())), "GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())), + "WIPEOUTVARIABLES" => self.wipeout_frame = T(tags).i(70).filter(|v| (0..=2).contains(v)), "SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => { if let Some(t) = sortents_table(tags) { self.sortents.push(t); @@ -2052,6 +2055,9 @@ impl Objects { } fn apply(self, d: &mut Drawing, rx: &Rx) { + if let Some(v) = self.wipeout_frame { + d.header.set_i64("WIPEOUTFRAME", v); + } if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { d.header.set_str("CMLEADERSTYLE", &style); } diff --git a/crates/io/tests/wipeout_frame.rs b/crates/io/tests/wipeout_frame.rs new file mode 100644 index 000000000..5c2c201c9 --- /dev/null +++ b/crates/io/tests/wipeout_frame.rs @@ -0,0 +1,21 @@ +//! The drawing's WIPEOUTVARIABLES object sets WIPEOUTFRAME: AutoCAD files with hidden wipeout +//! frames (group 70 = 0) must not show them. + +fn dxf(frame: Option) -> String { + let mut s = String::from(" 0\nSECTION\n 2\nENTITIES\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n"); + if let Some(f) = frame { + s.push_str(&format!(" 0\nWIPEOUTVARIABLES\n 5\nA1\n100\nAcDbWipeoutVariables\n 70\n{f}\n")); + } + s.push_str(" 0\nENDSEC\n 0\nEOF\n"); + s +} + +#[test] +fn wipeoutvariables_sets_the_frame_flag() { + let frame = |f| cadcraft_io::read_dxf(dxf(f).as_bytes()).unwrap().header.i64("WIPEOUTFRAME", 1); + assert_eq!(frame(Some(0)), 0, "frames hidden"); + assert_eq!(frame(Some(1)), 1); + assert_eq!(frame(Some(2)), 2); + assert_eq!(frame(None), 1, "no object: the default shows frames"); + assert_eq!(frame(Some(7)), 1, "an out-of-range flag is ignored"); +} From 0de051049cd4229c87d25d47def3c15913cfc1d3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:24 +0100 Subject: [PATCH 60/91] Keep associative-array parameters when an xref is loaded (#943) (#966) * Keep associative-array parameters when an xref is loaded (#943) * xref load test: initialise Ctx's new bytes counter Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks/xref/load.rs | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index efe9143a8..fac289f23 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -420,6 +420,17 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S nb.anonymous = b.anonymous; nb.explodable = b.explodable; nb.units = b.units; + // Array parameters keep their kind and spacing; block names follow the block renaming, and + // a source edit in progress in the referenced file is not carried over. + nb.array = b.array.as_ref().map(|a| { + let mut a = a.clone(); + a.source = dep(Sym::Block, &a.source).unwrap_or(a.source); + for r in &mut a.replaced { + r.block = dep(Sym::Block, &r.block).unwrap_or_else(|| r.block.clone()); + } + a.editing = None; + a + }); for e in b.entities.iter() { let e = adopt(d, e); nb.entities.push(e); @@ -532,4 +543,35 @@ mod tests { assert!(!has_prefix("SITE", "site|")); assert!(!has_prefix("é", "ab")); } + + #[test] + fn imported_array_blocks_keep_their_parameters() { + use cadcraft_doc::{ArrayKind, ArrayParams, Block, RectArray, ReplacedItem}; + let rect = RectArray { cols: 3, rows: 2, col_spacing: 5.0, row_spacing: 4.0, axis_angle: 0.0, row_elevation: 0.0 }; + let params = ArrayParams { + source: "*U1".into(), + kind: ArrayKind::Rect(rect), + levels: 1, + level_spacing: 0.0, + replaced: vec![ReplacedItem { i: 1, j: 0, level: 0, block: "ALT".into() }], + editing: Some(Default::default()), + }; + let mut arr = Block::new("*U2"); + arr.array = Some(params); + let mut x = Drawing::default(); + x.blocks.insert("*U2".into(), Arc::new(arr)); + + let mut d = Drawing::default(); + d.blocks.insert("SITE".into(), Arc::new(Block::new("SITE"))); + let mut cx = Ctx { chain: Vec::new(), objects: 0, files: 0, bytes: 0, notes: Vec::new() }; + assert!(import(&mut d, "SITE", &x, &mut cx).is_ok()); + + let got = d.blocks.get("SITE|*U2").and_then(|b| b.array.clone()); + let Some(got) = got else { panic!("array parameters were dropped") }; + assert_eq!(got.source, "SITE|*U1"); + assert_eq!(got.replaced[0].block, "SITE|ALT"); + assert!(got.editing.is_none()); + let ArrayKind::Rect(r) = got.kind else { panic!("not a rectangular array") }; + assert_eq!((r.cols, r.rows), (3, 2)); + } } From 2994b52e0357e026addb82cf019e20a541eb0a19 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:28 +0100 Subject: [PATCH 61/91] Read dynamic block element tags from the entity's own xdata (#942) (#967) * Read dynamic block element tags from the entity's own xdata (#942) * dynblock test: an untagged object at index 1 is keyed 1_000_001 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/doc/src/dynblock/apply.rs | 46 +++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index 70ef484eb..ed5281bf6 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -29,16 +29,21 @@ fn tag(code: i32, value: impl Into) -> RawTag { RawTag { code, value: value.into() } } -/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one. -fn etag(d: &Drawing, h: Handle) -> Option { - let x = &d.foreign.entities.get(&h)?.xdata; +/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one. +fn etag_in(x: &[RawTag]) -> Option { let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?; x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok()) } +/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the +/// foreign data. +fn etag(d: &Drawing, e: &Entity) -> Option { + etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata)) +} + /// Each object of `b` keyed by its element tag, or by its position when it has none. fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> { - b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect() + b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect() } /// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it. @@ -542,6 +547,12 @@ fn apply( let Some(nh) = copies.get(&e.handle).copied() else { continue }; let mut c: Entity = (**e).clone(); c.handle = nh; + // The copy's own xdata names the copy, not its original (as the kept data below does). + for t in &mut c.common.xdata { + if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) { + t.value = nh.hex(); + } + } if let Some(v) = shown_copy.get(&e.handle) { c.common.visible = *v; } @@ -964,3 +975,30 @@ fn drop_if_unused(d: &mut Drawing, name: &str) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tagged(h: u64, tag_value: Option<&str>) -> Entity { + let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO })); + if let Some(v) = tag_value { + e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)]; + } + e + } + + #[test] + fn element_tags_are_read_from_the_entitys_own_xdata() { + let d = Drawing::new_imperial(); + let mut def = Block::new("DEF"); + def.entities.push(tagged(1, Some("10"))); + def.entities.push(tagged(2, Some("20"))); + let mut anon = Block::new("*U1"); + // Reordered, and the first object left out. + anon.entities.push(tagged(5, Some("20"))); + anon.entities.push(tagged(6, None)); + assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]); + assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]); + } +} From d0a1ea526811d12e67135f0f8073c1f600f39a8b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:31 +0100 Subject: [PATCH 62/91] Table commands reject a malformed handle instead of using the selected table (#934) (#970) --- crates/engine/src/cmd/table.rs | 25 +++++++++--- crates/engine/src/cmd/table/cells.rs | 11 +---- crates/engine/tests/table_malformed_handle.rs | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/table_malformed_handle.rs diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index be156fb1d..f73222866 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -141,15 +141,28 @@ fn run_table(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": h.hex() })) } -/// Modify the table `handle` with `f`. -fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result { - let h = p - .get("handle") - .and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) +/// The table `handle`, or else the selected table. A supplied `handle` that can't be read is an +/// error, never a silent fall back to the selection (like `targets`). +fn table_handle(s: &Session, p: &Value, id: &str) -> Result { + let explicit = match p.get("handle") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(Handle::parse_hex) + .or_else(|| v.as_u64().map(Handle)) + .ok_or_else(|| bad(id, format!("`handle`: {v} is not an object handle (a hex string such as \"1A2\")")))?, + ), + }; + explicit .or_else(|| { s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_)))) }) - .ok_or_else(|| bad(id, "`handle` of a table is required"))?; + .ok_or_else(|| bad(id, "`handle` of a table is required")) +} + +/// Modify the table `handle` with `f`. +fn edit(s: &mut Session, p: &Value, id: &str, f: impl FnOnce(&mut Table) -> std::result::Result<(), String>) -> Result { + let h = table_handle(s, p, id)?; let mut t = match s.doc()?.entity(h).map(|e| &e.kind) { Some(EntityKind::Table(t)) => t.clone(), _ => return Err(bad(id, "object is not a table")), diff --git a/crates/engine/src/cmd/table/cells.rs b/crates/engine/src/cmd/table/cells.rs index a2efca4d6..d020758d8 100644 --- a/crates/engine/src/cmd/table/cells.rs +++ b/crates/engine/src/cmd/table/cells.rs @@ -6,7 +6,7 @@ use cadcraft_geom::Vec2; use serde_json::{Value, json}; use super::formula::{self, cell_name, parse_cell_name}; -use super::{MAX_COLS, MAX_ROWS, edit, usize_param}; +use super::{MAX_COLS, MAX_ROWS, edit, table_handle, usize_param}; use crate::cmd::{CommandSpec, bad, bool_or, str_param}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; @@ -524,15 +524,6 @@ fn write_text(path: &str, text: &str) -> Result<()> { } } -fn table_handle(s: &Session, p: &Value, id: &str) -> Result { - p.get("handle") - .and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) - .or_else(|| { - s.selection().into_iter().find(|h| s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(e.kind, EntityKind::Table(_)))) - }) - .ok_or_else(|| bad(id, "`handle` of a table is required")) -} - fn run_export(s: &mut Session, p: &Value) -> Result { let id = "tableexport"; let h = table_handle(s, p, id)?; diff --git a/crates/engine/tests/table_malformed_handle.rs b/crates/engine/tests/table_malformed_handle.rs new file mode 100644 index 000000000..849557494 --- /dev/null +++ b/crates/engine/tests/table_malformed_handle.rs @@ -0,0 +1,40 @@ +//! A malformed `handle` on a table edit command or TABLEEXPORT is an error. It never falls back +//! to the selected table (#934, follow-up to #330). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use serde_json::json; + +fn cell_text(s: &Session, h: &str) -> String { + let h = Handle::parse_hex(h).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Table(t) => t.cells[0][0].text.clone(), + _ => String::new(), + } +} + +#[test] +fn malformed_table_handle_does_not_edit_the_selected_table() { + let mut s = Session::new(); + let h = s.execute("table", &json!({ "at": [0, 0], "cells": [["a", "b"]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap()["handle"] + .as_str() + .unwrap() + .to_string(); + s.set_selection(vec![Handle::parse_hex(&h).unwrap()]); + + for bad in [json!("zzz"), json!(""), json!("#1"), json!(-1), json!(true), json!([1])] { + let e = s.execute("table.set", &json!({ "handle": bad, "row": 0, "col": 0, "text": "X" })).expect_err(&format!("handle {bad}")); + assert!(e.to_string().contains("is not an object handle"), "handle {bad}: {e}"); + assert_eq!(cell_text(&s, &h), "a", "handle {bad} must change nothing"); + // TABLEEXPORT resolves its table the same way: no CSV of the selected table either. + let e = s.execute("tableexport", &json!({ "handle": bad })).expect_err(&format!("tableexport handle {bad}")); + assert!(e.to_string().contains("is not an object handle"), "tableexport handle {bad}: {e}"); + } + + // An explicit valid handle and an omitted handle (selection) still work. + s.execute("table.set", &json!({ "handle": h, "row": 0, "col": 0, "text": "Y" })).unwrap(); + assert_eq!(cell_text(&s, &h), "Y"); + s.execute("table.set", &json!({ "row": 0, "col": 0, "text": "Z" })).unwrap(); + assert_eq!(cell_text(&s, &h), "Z"); + assert_eq!(s.execute("tableexport", &json!({})).unwrap()["csv"], "Z,b\r\n"); +} From c3c455fbbf4c4babcb8fb4ea7f6ed7f0c76edf6b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:34 +0100 Subject: [PATCH 63/91] Region: test very long segments against every segment so no crossing is missed (#929) (#975) --- crates/geom/src/region.rs | 61 ++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 98050cfdc..bef60b530 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -60,6 +60,21 @@ struct Graph { edge_src: HashMap<(usize, usize), usize>, } +/// Records the crossing of segments `i` and `j`, if any, as split parameters on both. +fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec], i: usize, j: usize) { + let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { return }; + let li = Line::new(si.0, si.1); + let lj = Line::new(sj.0, sj.1); + if let Some(x) = line_line(&li, &lj) { + if let Some(v) = splits.get_mut(i) { + v.push(li.param_of(x).clamp(0.0, 1.0)); + } + if let Some(v) = splits.get_mut(j) { + v.push(lj.param_of(x).clamp(0.0, 1.0)); + } + } +} + fn build(polys: &[Vec], tol: f64) -> Option { let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); let mut owner: Vec = Vec::new(); @@ -94,16 +109,13 @@ fn build(polys: &[Vec], tol: f64) -> Option { ) }; let mut grid: HashMap<(usize, usize), Vec> = HashMap::new(); + let mut long: Vec = 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: register in a coarse band. - for gx in (x0..=x1).step_by(((x1 - x0) / 64).max(1)) { - for gy in (y0..=y1).step_by(((y1 - y0) / 64).max(1)) { - grid.entry((gx, gy)).or_default().push(i); - } - } + // 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 { @@ -130,23 +142,24 @@ fn build(polys: &[Vec], tol: f64) -> Option { if i == j || !tested.insert((i.min(j), i.max(j))) { continue; } - let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { continue }; - let li = Line::new(si.0, si.1); - let lj = Line::new(sj.0, sj.1); - if let Some(x) = line_line(&li, &lj) { - if let Some(v) = splits.get_mut(i) { - v.push(li.param_of(x).clamp(0.0, 1.0)); - } - if let Some(v) = splits.get_mut(j) { - v.push(lj.param_of(x).clamp(0.0, 1.0)); - } - } + 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 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 { @@ -609,6 +622,20 @@ 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)] } + #[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. + let mut polys = vec![vec![Vec2::new(0.0, 0.0), Vec2::new(1000.0, 1000.0)], vec![Vec2::new(11.0, 12.0), Vec2::new(12.0, 11.0)]]; + for i in 0..17_000usize { + let x = 600.0 + (i % 130) as f64 * 2.0; + let y = 100.0 + (i / 130) as f64 * 2.0; + polys.push(vec![Vec2::new(x, y), Vec2::new(x + 1.0, y)]); + } + let g = build(&polys, 1e-6).unwrap(); + let hit = g.pts.iter().position(|p| p.near(Vec2::new(11.5, 11.5), 1e-6)).unwrap(); + assert_eq!(g.adj[hit].len(), 4); + } + #[test] fn finds_square_around_point() { let l = enclosing_loop(&[square(0.0, 0.0, 10.0)], Vec2::new(5.0, 5.0), 1e-9).unwrap(); From 3f92d57b79cbd3b8716ab9ce4ba47c99c50c0df4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:38 +0100 Subject: [PATCH 64/91] Geometry: circle intersection judges tangency against the smaller circle, so a small circle crossing a huge one gives two points (#926) (#976) --- crates/geom/src/intersect.rs | 22 +++++++++++++++------- crates/geom/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/geom/src/intersect.rs b/crates/geom/src/intersect.rs index 1451a94bd..653cd61c9 100644 --- a/crates/geom/src/intersect.rs +++ b/crates/geom/src/intersect.rs @@ -62,22 +62,29 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec { // Work at the scale of the two circles: fixed absolute tolerances classify // small secants as tangencies, and raw radius squares overflow on large drawings. + // Measure the chord from the smaller circle: from the larger one the half-chord is a + // difference of nearly equal numbers, so a tiny circle crossing a huge one is lost. + let swapped = r2 < r1; + let (from, dir) = if swapped { (c2.center, -(delta / d)) } else { (c1.center, delta / d) }; let scale = d.max(r1).max(r2); let (ds, r1s, r2s) = (d / scale, r1 / scale, r2 / scale); if ds > r1s + r2s || ds < (r1s - r2s).abs() { return Vec::new(); } - let a = ((r1s - r2s) * (r1s + r2s) + ds * ds) / (2.0 * ds); - let h2 = (r1s - a) * (r1s + a); - if h2 < -1e-12 { + let (rs, ro) = if swapped { (r2s, r1s) } else { (r1s, r2s) }; + let a = ((rs - ro) * (rs + ro) + ds * ds) / (2.0 * ds); + let h2 = (rs - a) * (rs + a); + // Tangency is judged against the smaller circle, which sets the size of the chord, with + // room for the rounding in `a` (a few ulps of the unit scale over ds, times 2a in h2). + let tol = (1e-12 * rs * rs).max(8.0 * f64::EPSILON * rs / ds); + if h2 < -tol { return Vec::new(); } - let dir = delta / d; - let p = c1.center + dir * (a * scale); + let p = from + dir * (a * scale); if !p.is_finite() { return Vec::new(); } - if h2 <= 1e-12 { + if h2 <= tol { return vec![p]; } let step = dir.perp() * (h2.sqrt() * scale); @@ -85,7 +92,8 @@ pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec { if !p1.is_finite() || !p2.is_finite() { return Vec::new(); } - vec![p1, p2] + // Keep the result order independent of which circle was used as the base. + if swapped { vec![p2, p1] } else { vec![p1, p2] } } fn on_arc(a: &Arc, p: Vec2) -> bool { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index a452e610d..915a1071e 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -119,6 +119,29 @@ fn circle_circle_hits() { assert!(h.iter().all(|p| close(p.x, 4.0) && close(p.y.abs(), 3.0))); } +#[test] +fn small_circle_crossing_a_huge_circle_gives_two_hits() { + // Radii 1e7 and 1 with centres 1e7 apart: the small circle crosses the large one. + let big = Circle::new(Vec2::ZERO, 1e7); + let small = Circle::new(Vec2::new(1e7, 0.0), 1.0); + for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] { + assert_eq!(hits.len(), 2, "{hits:?}"); + for p in &hits { + assert!((p.dist(small.center) - 1.0).abs() < 1e-6, "{p:?} is off the small circle"); + assert!((p.dist(big.center) - 1e7).abs() < 1e-6, "{p:?} is off the big circle"); + } + assert!(hits[0].dist(hits[1]) > 1.0, "{hits:?}"); + } + // Touching from outside and from inside is still one tangent point, in both orders. + for (x, touch) in [(1e7 + 1.0, 1e7), (1e7 - 1.0, 1e7)] { + let small = Circle::new(Vec2::new(x, 0.0), 1.0); + for hits in [circle_circle(&big, &small), circle_circle(&small, &big)] { + assert_eq!(hits.len(), 1, "{hits:?}"); + assert!(hits[0].dist(Vec2::new(touch, 0.0)) < 1e-6, "{hits:?}"); + } + } +} + #[test] fn tiny_circle_secants_and_separated_circles() { let first = Circle::new(Vec2::ZERO, 1e-9); From 2528671106070105c30440f2fc6142f226498f17 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:41 +0100 Subject: [PATCH 65/91] Cap HATCH spline edge degree at 10 like the SPLINE reader (#923) (#978) --- crates/io/src/dxf_read.rs | 2 +- crates/io/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index e71f7725f..4c797ed9e 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -734,7 +734,7 @@ fn hatch(tags: &[Tag], t: &T) -> Option { while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) { let n = x.i64().clamp(0, 100_000) as usize; match x.code { - 94 => degree = n, + 94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader 95 => nk = n, 96 => nc = n, _ => {} diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 7b8f38385..d5fe5d2f7 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2139,6 +2139,30 @@ fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() { near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]); } +/// Issue #923: a HATCH spline edge caps its degree at 10 like the SPLINE reader does, both when the +/// knots are valid for the stated degree and when the reader falls back to the control polygon. +#[test] +fn hatch_spline_edge_caps_degree_like_spline() { + let control: Vec = (0..13).map(|i| Vec2::new(f64::from(i), f64::from(i % 2) * 10.0)).collect(); + let points: String = control.iter().map(|c| format!("10\n{}\n20\n{}\n", c.x, c.y)).collect(); + let close = "72\n1\n10\n12\n20\n0\n11\n0\n21\n0\n"; + // The degree-10 curve over the same controls, which is what the capped edge must produce. + let want = cadcraft_geom::Spline::from_control(control, 10).tessellate(1e-3); + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in &want { + (lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))); + } + // Degree 12 with 13 controls: a valid knot vector has 26 knots; the second edge gives none. + let knots = format!("{}{}", "40\n0\n".repeat(13), "40\n1\n".repeat(13)); + for (nk, knots) in [(26, knots.as_str()), (0, "")] { + let edge = format!("72\n4\n94\n12\n73\n0\n74\n0\n95\n{nk}\n96\n13\n{knots}{points}97\n0\n{close}"); + let b = hatch_edge_extents(&edge); + for (got, want) in [(b.min.x, lo.x), (b.min.y, lo.y), (b.max.x, hi.x), (b.max.y, hi.y)] { + assert!((got - want).abs() < 1e-6, "extents {b:?}, want {lo:?} {hi:?} (knots {nk})"); + } + } +} + #[test] fn mirrored_text_extrusion_keeps_its_footprint() { // TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with From 8343045863dc1ace20ee3e3e378cdac639dbcf94 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:44 +0100 Subject: [PATCH 66/91] Fix grid index span overflow with a very large view (#920) (#979) --- crates/ui-egui/src/canvas.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 81c79bbf8..a2812367a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -692,6 +692,18 @@ fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { u } +/// Index ranges of the grid lines inside `wb` for line spacing `minor`, or `None` when there would be +/// 2000 lines or more along an axis. The span is computed with checked arithmetic: a huge view +/// saturates the float-to-integer casts at the i64 limits, and subtracting those would overflow. +fn grid_index_ranges(wb: &Bounds2, minor: f64) -> Option<((i64, i64), (i64, i64))> { + let range = |lo: f64, hi: f64| { + let a = (lo / minor).floor() as i64; + let b = (hi / minor).ceil() as i64; + b.checked_sub(a).filter(|n| *n < 2000).map(|_| (a, b)) + }; + Some((range(wb.min.x, wb.max.x)?, range(wb.min.y, wb.max.y)?)) +} + fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { let t = Tokens::get(); let s = app.session.drafting_settings(); @@ -704,11 +716,7 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { let minor = adaptive(unit, xf.scale, 8.0); let major = minor * major_n; let mut lines = Vec::new(); - let x0 = (wb.min.x / minor).floor() as i64; - let x1 = (wb.max.x / minor).ceil() as i64; - let y0 = (wb.min.y / minor).floor() as i64; - let y1 = (wb.max.y / minor).ceil() as i64; - if (x1 - x0) < 2000 && (y1 - y0) < 2000 { + if let Some(((x0, x1), (y0, y1))) = grid_index_ranges(&wb, minor) { for i in x0..=x1 { let x = i as f64 * minor; let is_major = (x / major).round() * major - x == 0.0 || ((x / major) - (x / major).round()).abs() < 1e-6; @@ -2995,3 +3003,16 @@ mod hatch_pick_tests { assert!(app.session.running.is_none(), "{:?}", app.session.running.as_ref().map(|r| r.machine.name())); } } + +#[cfg(test)] +mod grid_range_tests { + use cadcraft_geom::{Bounds2, Vec2}; + + #[test] + fn grid_index_ranges_survive_extreme_views() { + let huge = Bounds2::new(Vec2::new(-1e300, -1e300), Vec2::new(1e300, 1e300)); + assert_eq!(super::grid_index_ranges(&huge, 1.0), None); + let normal = Bounds2::new(Vec2::new(-5.0, 0.0), Vec2::new(5.0, 10.0)); + assert_eq!(super::grid_index_ranges(&normal, 1.0), Some(((-5, 5), (0, 10)))); + } +} From d73e379fd24068d73563f2e895f05bdd471b3a6a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:48 +0100 Subject: [PATCH 67/91] Autosave no longer writes the old host as recovery when the Block Editor's changes can't be composed (#918) (#980) --- crates/engine/src/autosave.rs | 9 +++++-- crates/engine/src/blockedit.rs | 11 +++++---- crates/engine/tests/autosave.rs | 38 +++++++++++++++++++++++++++++ crates/engine/tests/block_editor.rs | 2 +- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 0904711e4..4da107449 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -208,6 +208,12 @@ impl Autosaver { if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) { continue; } + // In the Block Editor: the drawing with the editor's unsaved changes in the block. When + // they can't be put there, nothing is written and nothing is marked as covered, so the + // next pass tries again instead of keeping the host without them. + let Ok(doc) = st.recovery_drawing() else { + continue; + }; let path = t .file .clone() @@ -216,8 +222,7 @@ impl Autosaver { t.written = Some(Arc::downgrade(&st.doc)); t.host = Some(Arc::downgrade(st.drawing())); t.since = None; - // In the Block Editor: the drawing with the editor's unsaved changes in the block. - jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() }); + jobs.push(Job::Write { uid: st.uid, path, doc }); } jobs } diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index 776909a68..4dab97f1f 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -195,12 +195,13 @@ impl DocState { self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc)) } /// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the - /// block, so a crash doesn't lose them. - pub fn recovery_drawing(&self) -> Arc { + /// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the + /// block (it would contain itself): the host drawing alone would lose them. + pub fn recovery_drawing(&self) -> Result> { match &self.block_edit { - Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()), - Some(be) => be.host.doc.clone(), - None => self.doc.clone(), + Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new), + Some(be) => Ok(be.host.doc.clone()), + None => Ok(self.doc.clone()), } } diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index 0f0ec7da7..da03c0d7d 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -322,3 +322,41 @@ fn hooks() { plot: None, }); } + +#[test] +fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() { + use cadcraft_engine::doc::{Entity, EntityKind, Insert}; + use cadcraft_engine::geom::Vec3; + + let mut s = Session::new(); + let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone(); + s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap(); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + // An unsaved change in the editor: the block would contain a reference to itself. + let d = s.doc_mut().unwrap(); + let h = d.new_handle(); + let kind = EntityKind::Insert(Insert { + block: "Door".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }); + d.model.push(Entity::new(h, kind)); + assert!(s.state().unwrap().recovery_drawing().is_err()); + + let mut a = Autosaver::new("55-b"); + a.plan(&s, 0.0, 1, "/auto"); + assert!(a.plan(&s, 60.0, 1, "/auto").is_empty(), "no recovery file without the editor's changes"); + assert!(a.plan(&s, 120.0, 1, "/auto").is_empty()); + assert_eq!(a.next_due(120.0, 1), Some(0.0), "still waiting to be written"); + + // Once the reference is gone, the next autosave writes the editor's changes. + let d = s.doc_mut().unwrap(); + d.model.remove(h); + assert_eq!(writes(&a.plan(&s, 180.0, 1, "/auto")).len(), 1); +} diff --git a/crates/engine/tests/block_editor.rs b/crates/engine/tests/block_editor.rs index 3b1404f1f..711bcd796 100644 --- a/crates/engine/tests/block_editor.rs +++ b/crates/engine/tests/block_editor.rs @@ -217,7 +217,7 @@ fn a_file_save_writes_the_drawing_outside_the_editor() { s.execute("bedit", &json!({ "name": "Door" })).unwrap(); line(&mut s, [0.0, 0.0], [0.0, 3.0]); // Autosave keeps the editor's unsaved change in the block. - let rec = s.state().unwrap().recovery_drawing(); + let rec = s.state().unwrap().recovery_drawing().unwrap(); assert_eq!(rec.block("Door").unwrap().entities.len(), 2); assert_eq!(rec.model.len(), 2); From d6e712db41d6a02dd060490c776e1a8c865f0a60 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:25:51 +0100 Subject: [PATCH 68/91] CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912) (#982) * CLI --save, convert and sample write through a staged file so a failed write keeps the previous drawing (#912) * cli: write through the engine's atomic_write instead of a second copy Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/cadcraft-cli/src/main.rs | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 32c781ec4..99a8b4282 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -226,6 +226,12 @@ fn info(path: &str) -> Result<(), String> { Ok(()) } +/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file +/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. +fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> { + cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes) +} + fn export(s: &Session, out: &str) -> Result<(), String> { export_framed(s, out, None) } @@ -233,7 +239,7 @@ fn export(s: &Session, out: &str) -> Result<(), String> { fn export_framed(s: &Session, out: &str, window: Option) -> Result<(), String> { let d = s.doc().map_err(|e| e.to_string())?; let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({} bytes)", bytes.len()); if cadcraft_engine::cmd::file::drops_unknown(out) { let lost = cadcraft_engine::cmd::file::not_saved(d); @@ -252,7 +258,7 @@ fn sample(args: &[String]) -> Result<(), String> { other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")), }; let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?; - std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; + write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({} bytes)", bytes.len()); Ok(()) } @@ -862,6 +868,37 @@ fn cli() -> ExitCode { mod tests { use super::*; + fn scratch_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + #[test] + fn write_atomic_replaces_file_and_leaves_no_staging_files() { + let dir = scratch_dir("replace"); + let out = dir.join("a.dxf"); + let out = out.to_str().expect("utf-8 path"); + write_atomic(out, b"old").expect("create"); + write_atomic(out, b"new").expect("overwrite"); + assert_eq!(std::fs::read(out).expect("read"), b"new"); + assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_failure_keeps_destination_and_cleans_up() { + let dir = scratch_dir("fail"); + let out = dir.join("target"); + std::fs::create_dir(&out).expect("dir as destination"); + std::fs::write(out.join("keep"), b"x").expect("seed"); + assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err()); + assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x"); + assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 { Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)) } From 7fc9f2756d80f795cb15f65aec01063eedfb7180 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:02:27 -0400 Subject: [PATCH 69/91] Every craft-libs crate at craft-libs main a0916808 (#2153) craft-ui and craft-docpath were pinned at intermediate commits of the egui 0.37 port (craft-libs#268), and the PDF stack at 6e7f285. a0916808 is main after #268 merged and contains all three. --- ATTRIBUTION.md | 8 ++++---- Cargo.lock | 12 ++++++------ Cargo.toml | 14 ++++++-------- NOTICE | 4 ++-- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b9ba5eb5..54439c12e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | -| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | -| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | -| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | -| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 6e7f285 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 2257e9826..3ad658f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -956,12 +956,12 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "hayro-syntax", "krilla", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", "log", @@ -1855,7 +1855,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "hayro-ccitt", ] @@ -1869,7 +1869,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2215,7 +2215,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 8334b1630..06602b915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false } # foundation thiserror = "2" @@ -53,9 +53,7 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -# 650b1c0 is the reviewed craft-libs integration: 25da's egui-0.37 port plus 6e7f's -# shared docking `Workspace`/`DockCommand`. Raw 6e7f is egui 0.36 and is not consumable here. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = "0.37" eframe = { version = "0.37", default-features = false } @@ -91,9 +89,9 @@ debug = 0 # PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself # (Cargo ignores dependency-local patches). [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # Keep the complete egui family and native event bridge on reviewed fork commits. ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index e800c1d45..3e6cc03a3 100644 --- a/NOTICE +++ b/NOTICE @@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc -(pinned through https://github.com/storytold/craft-libs/tree/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc). +(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc). CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ -Pinned licence text: https://github.com/storytold/craft-libs/blob/6e7f28518dd1cb57e1624bdc269c3c14316b13ae/vendor/krilla/icc/LICENSE +Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts and registered through egui's default font definitions. They derive from emoji-icon-font by From 2f82ecdbf1021cb0b30d6755c062973172281bda Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:52:54 -0400 Subject: [PATCH 70/91] feat: startup update prompt modal and direct self-updating (#2141) * feat: startup update prompt modal and direct self-updating Ref: storytold/craft-libs#319 * build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock * refactor(updates): drive the flow with craft-update's Updater Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored. --- Cargo.lock | 505 +++++++++++++++++++++++++++++- Cargo.toml | 1 + apps/cadcraft/Cargo.toml | 1 + apps/cadcraft/src/main.rs | 10 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pt_br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh.tsv | 3 + crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/updates_ui.rs | 71 +++++ 23 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/updates_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 3ad658f32..5bfaa7035 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "basic-udev" version = "0.1.2" @@ -472,6 +478,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -556,6 +571,7 @@ dependencies = [ "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "craft-update", "eframe", "egui", "hidapi", @@ -723,6 +739,7 @@ dependencies = [ "cadcraft-render", "chrono", "craft-ui", + "craft-update", "egui", "egui-wgpu", "egui_kittest", @@ -917,6 +934,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -930,7 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -943,7 +970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -953,6 +980,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -981,6 +1017,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1030,12 +1091,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1049,6 +1126,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1108,6 +1195,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" version = "0.37.0" @@ -1432,6 +1525,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1592,6 +1695,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1611,6 +1724,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1630,8 +1754,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -1911,11 +2037,27 @@ dependencies = [ "cc", "cfg-if", "libc", - "nix", + "nix 0.30.1", "pkg-config", "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -2217,7 +2359,7 @@ name = "krilla" version = "0.8.2" source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ - "base64", + "base64 0.22.1", "bumpalo", "flate2", "float-cmp", @@ -2531,6 +2673,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2556,6 +2710,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2924,6 +3084,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3144,6 +3310,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3216,6 +3395,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3435,6 +3620,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.2" @@ -3507,6 +3706,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3528,6 +3801,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3540,6 +3822,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3551,6 +3856,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -3621,6 +3930,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3841,6 +4161,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -3883,6 +4209,16 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3959,6 +4295,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -4113,12 +4479,24 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4175,6 +4553,41 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4187,6 +4600,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4231,6 +4650,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4247,6 +4676,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4483,6 +4918,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4896,7 +5340,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -4941,6 +5385,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -4951,6 +5405,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5241,6 +5701,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5274,6 +5740,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix 0.31.3", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -5286,6 +5769,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 06602b915..087100fee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 8d9363a67..4c88f2ad6 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -34,6 +34,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } rfd = { workspace = true } sys-locale = "0.3" diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index a9bdb343a..45852da2f 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -246,6 +246,15 @@ fn control_flag(current: Option, value: Option) -> Option { fn main() -> eframe::Result { // First, so every start-up record is captured; see `logging`. let logger = logging::install(); + // The update helper relaunches this binary after a self-update; it runs and exits here. + #[cfg(not(target_arch = "wasm32"))] + if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Err(e) = done { + eprintln!("CADCraft update helper failed: {e}"); + std::process::exit(1); + } + return Ok(()); + } // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let mut control_port = control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned())); @@ -352,6 +361,7 @@ fn main() -> eframe::Result { // Autosave files (SAVEFILEPATH) default to `/autosave`. if let Some(dir) = config_dir() { app.enable_autosave(&dir.join("autosave")); + app.enable_update_check(); // Startup LISP (acad.lsp, acaddoc.lsp) is read from `/support`; the // folder is made so it is there to put them in. let support = dir.join("support"); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 12abfdd99..aee4c47f7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,9 @@ chrono = { workspace = true } image = { workspace = true } web-time = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } + [dev-dependencies] # The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in # production too; the tests exercise the same installed chain as the app. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index fcf3a7ace..9ada44442 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1237,3 +1237,6 @@ menu Modify تعديل menu View عرض @plural {n} commit {n} إيداع|{n} إيداع|{n} إيداعان|{n} إيداعات|{n} إيداعاً|{n} إيداع @plural {n} contributor {n} مساهم|{n} مساهم|{n} مساهمان|{n} مساهمين|{n} مساهماً|{n} مساهم + Check for Updates… التحقق من وجود تحديثات… + CADCraft is up to date. CADCraft محدّث. + Could not check for updates. تعذّر التحقق من وجود تحديثات. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2a393d18b..6df6a4168 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1222,3 +1222,6 @@ menu Modify Modifikace Not a number or distance: {text} Není číslo ani vzdálenost: {text} Query Texts Dotaz na texty Analyze Shapes Analyzovat tvary + Check for Updates… Zkontrolovat aktualizace… + CADCraft is up to date. CADCraft je aktuální. + Could not check for updates. Aktualizace se nepodařilo zkontrolovat. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4f7bac7a1..860b23136 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1039,3 +1039,6 @@ Justify Blocksatz Text color Textfarbe Click the ruler to add a tab stop, right-click to remove one Klick auf das Lineal setzt einen Tabstopp, Rechtsklick entfernt ihn + Check for Updates… Nach Updates suchen… + CADCraft is up to date. CADCraft ist auf dem neuesten Stand. + Could not check for updates. Die Suche nach Updates ist fehlgeschlagen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7a12938b1..fe09face6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1333,3 +1333,6 @@ menu Dimension Διαστασιολόγηση menu Draw Σχεδίαση menu Modify Τροποποίηση menu View Προβολή + Check for Updates… Έλεγχος για ενημερώσεις… + CADCraft is up to date. Το CADCraft είναι ενημερωμένο. + Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9fc92d32e..afa3c90a5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1251,3 +1251,6 @@ Not a number or distance: {text} No es un número ni una distancia: {text} Query Texts Consultar textos Analyze Shapes Analizar formas + Check for Updates… Buscar actualizaciones… + CADCraft is up to date. CADCraft está actualizado. + Could not check for updates. No se pudo buscar actualizaciones. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4398423b0..30639fe83 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1012,3 +1012,6 @@ Not a number or distance: {text} Ni un nombre ni une distance : {text} Query Texts Interroger les textes Analyze Shapes Analyser les formes + Check for Updates… Rechercher des mises à jour… + CADCraft is up to date. CADCraft est à jour. + Could not check for updates. Impossible de rechercher les mises à jour. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 17b3317e6..67769707e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1344,3 +1344,6 @@ menu Dimension Méretezés menu Draw Rajzolás menu Modify Módosítás menu View Nézet + Check for Updates… Frissítések keresése… + CADCraft is up to date. A CADCraft naprakész. + Could not check for updates. A frissítések keresése nem sikerült. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fb033f675..27786b2e5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1362,3 +1362,6 @@ menu Modify Edita Click the ruler to add a tab stop, right-click to remove one Fare clic sul righello per aggiungere una tabulazione, clic destro per rimuoverla Select using two clicks only Seleziona solo con due clic Define each corner of the selection window with a separate click. Definire ogni angolo della finestra di selezione con un clic separato. + Check for Updates… Verifica aggiornamenti… + CADCraft is up to date. CADCraft è aggiornato. + Could not check for updates. Impossibile verificare gli aggiornamenti. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index edb78377f..7c5c9016c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1242,3 +1242,6 @@ menu View 보기 menu Draw 그리기 menu Dimension 치수 menu Modify 수정 + Check for Updates… 업데이트 확인… + CADCraft is up to date. CADCraft가 최신 버전입니다. + Could not check for updates. 업데이트를 확인하지 못했습니다. diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index da316c00d..04fafff2f 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -1251,3 +1251,6 @@ Not a number or distance: {text} Não é um número nem uma distância: {text} Select using two clicks only Selecionar apenas com dois cliques Define each corner of the selection window with a separate click. Defina cada canto da janela de seleção com um clique separado. + Check for Updates… Verificar atualizações… + CADCraft is up to date. O CADCraft está atualizado. + Could not check for updates. Não foi possível verificar atualizações. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 547eb36a3..a5feb4589 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1013,3 +1013,6 @@ Not a number or distance: {text} Не число и не расстояние: {text} Query Texts Запрос текстов Analyze Shapes Анализ фигур + Check for Updates… Проверить обновления… + CADCraft is up to date. У вас последняя версия CADCraft. + Could not check for updates. Не удалось проверить обновления. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 03d51d535..2896520d3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1446,3 +1446,6 @@ menu Modify Ändra US Survey Yard Lantmäteriyard (USA) Yards Yard Your changes will be lost if you don't save them. Dina ändringar går förlorade om du inte sparar dem. + Check for Updates… Sök efter uppdateringar… + CADCraft is up to date. CADCraft är uppdaterat. + Could not check for updates. Det gick inte att söka efter uppdateringar. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a1c4aefec..cdf4ec36f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1347,3 +1347,6 @@ menu Dimension Ölçülendirme menu Draw Çiz menu Modify Değiştir menu View Görünüm + Check for Updates… Güncellemeleri denetle… + CADCraft is up to date. CADCraft güncel. + Could not check for updates. Güncellemeler denetlenemedi. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 815467f98..f75d7c529 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1368,3 +1368,6 @@ Discard Скасувати зміни Customize Налаштування інтерфейсу Edit Command Aliases (PGP)... Редагувати псевдоніми команд (PGP)... + Check for Updates… Перевірити оновлення… + CADCraft is up to date. Встановлено найновішу версію CADCraft. + Could not check for updates. Не вдалося перевірити оновлення. diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index ed96faff9..80183d064 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1344,3 +1344,6 @@ US Survey Inch 美国测量英寸 US Survey Yard 美国测量码 US Survey Mile 美国测量英里 + Check for Updates… 检查更新… + CADCraft is up to date. CADCraft 已是最新版本。 + Could not check for updates. 无法检查更新。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a157a05aa..000e158f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,8 @@ pub mod plotstyles; pub mod quick; pub mod shortcut_menus; pub mod theme; +#[cfg(not(target_arch = "wasm32"))] +pub mod updates_ui; pub mod viewcube; pub mod vpscale; @@ -274,6 +276,9 @@ pub struct CadApp { pub hatch_dlg: Option, /// The Edit Block Definition dialog ([`bedit`]). pub bedit: bedit::State, + /// The startup update check and its prompt (desktop; [`updates_ui`]). + #[cfg(not(target_arch = "wasm32"))] + pub updates_rt: updates_ui::Runtime, } impl CadApp { @@ -319,6 +324,8 @@ impl CadApp { alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), + #[cfg(not(target_arch = "wasm32"))] + updates_rt: updates_ui::Runtime::default(), } } @@ -607,6 +614,8 @@ impl CadApp { self.session.lisp_tick(); // Time-based and idempotent by its own deadline; keeps autosave alive while hidden. self.autosave_tick(ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); if !self.synthetic.is_empty() || self.synthetic_injected { request_paint_while_hidden(ctx); } @@ -770,6 +779,8 @@ impl CadApp { } }); self.opening.show(ui.ctx()); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, ui.ctx()); dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8a4476e39..480d5563d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -63,6 +63,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.qselect", "Quick Select...", &[], None), ("ui.dialog.parameters", "Parameters Manager", &["Window", "Parameters Manager"], None), ("ui.dialog.dsettings", "Drafting Settings...", &[], None), + #[cfg(not(target_arch = "wasm32"))] + ("ui.updates.check", "Check for Updates…", &["Help", "Check for Updates…"], None), ("ui.dialog.about", "About CADCraft", &["Help", "About CADCraft"], None), ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), @@ -372,6 +374,11 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + crate::updates_ui::check_now(app); + Ok(Value::Null) + } "ui.dialog.close" => { app.ui.dialog = None; Ok(Value::Null) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 000000000..1bd05c6f8 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,71 @@ +//! One background update check at startup, the "Update and Restart" prompt, and Help ▸ Check for +//! Updates… (desktop only; the web build is always the latest). The flow itself is +//! `craft_update::updater::Updater`. + +use craft_update::suite::CADCRAFT; +use craft_update::updater::{Action, Status, Updater}; + +use crate::CadApp; + +/// The version this build reports to the update check. +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// What the update check and prompt are doing between frames. +pub struct Runtime { + updater: Updater, + /// Whether the host asked for a startup check (see [`CadApp::enable_update_check`]). + enabled: bool, + /// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context. + check_requested: bool, +} + +impl Default for Runtime { + fn default() -> Self { + Self { updater: Updater::new(&CADCRAFT, VERSION), enabled: false, check_requested: false } + } +} + +impl CadApp { + /// Turn on the startup update check (the desktop host does; tests and the web build do not). + pub fn enable_update_check(&mut self) { + self.updates_rt.enabled = true; + } +} + +/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer. +pub fn check_now(app: &mut CadApp) { + app.updates_rt.check_requested = true; +} + +/// Start the checks and collect the answers. +pub fn tick(app: &mut CadApp, ctx: &egui::Context) { + let rt = &mut app.updates_rt; + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.enabled { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + let outcome = match rt.updater.status() { + Status::UpToDate => Some(crate::tl!("CADCraft is up to date.")), + Status::Failed(_) => Some(crate::tl!("Could not check for updates.")), + Status::Idle | Status::Checking => None, + }; + if let Some(message) = outcome { + rt.updater.dismiss_status(); + app.session.echo(message); + } +} + +/// Draw the prompt while there is one and act on its answer. +pub fn show(app: &mut CadApp, ctx: &egui::Context) { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = !app.session.docs.iter().any(|d| d.is_dirty()); + match app.updates_rt.updater.show_if(ctx, clean) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + // The guarded close path asks about unsaved changes; the restart helper relaunches once the + // process has exited. + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } +} From dfddd13656bde4ed7f6c579d85e34874650bc31a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:33:55 -0400 Subject: [PATCH 71/91] build: pin craft-update to craft-libs main (db19c37) (#2161) Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bfaa7035..f6623f914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 087100fee..f40c4ce6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } From 4feccf0abdfd294bd127b2948872d2c9ad6ceb8d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:01 -0400 Subject: [PATCH 72/91] build: pin craft-update to craft-libs main (495e597) (#2163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6623f914..455fa3219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index f40c4ce6c..9ceb02e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } From 876de8ad2f41fdd647a1e001d03656eed10ea204 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:52:38 -0400 Subject: [PATCH 73/91] build: pin craft-update to craft-libs main (495e597) (#2168) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 From 656dd4d09a2d41be7f878a36eae584d586caa652 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 18:18:29 -0400 Subject: [PATCH 74/91] refactor: explicit imports instead of wildcards (S2208, batch_00) (#2037) * refactor: replace wildcard imports with explicit ones (S2208, batch_00) * refactor: replace wildcard imports with explicit ones (S2208, batch_00) Explicit lists now resolve the names each file uses, and the test module keeps its glob so it still sees the parent's items. --- crates/engine/src/cmd/annoscale.rs | 3 ++- crates/engine/src/cmd/annotate.rs | 3 ++- crates/engine/src/cmd/annotate_dimedit.rs | 7 +++++-- crates/engine/src/cmd/annotate_qdim.rs | 6 ++++-- crates/engine/src/cmd/array.rs | 3 ++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/annoscale.rs b/crates/engine/src/cmd/annoscale.rs index 913cc5e6c..6ec2fb8f5 100644 --- a/crates/engine/src/cmd/annoscale.rs +++ b/crates/engine/src/cmd/annoscale.rs @@ -7,7 +7,8 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, f64_req, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 87b0d063c..d371d85af 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -9,7 +9,8 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, point_param, point_req, points_param, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/annotate_dimedit.rs b/crates/engine/src/cmd/annotate_dimedit.rs index 19538e0d5..87d1c2dca 100644 --- a/crates/engine/src/cmd/annotate_dimedit.rs +++ b/crates/engine/src/cmd/annotate_dimedit.rs @@ -5,8 +5,11 @@ use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection, use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::super::helpers::v3; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{CommandSpec, bad, bool_or, point_param, point_req, size_param, str_param, targets}; +use super::{DimText, add_dim, circle_of, dim, dims_of, pick_at}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension. diff --git a/crates/engine/src/cmd/annotate_qdim.rs b/crates/engine/src/cmd/annotate_qdim.rs index 49e604f9a..06a7b4956 100644 --- a/crates/engine/src/cmd/annotate_qdim.rs +++ b/crates/engine/src/cmd/annotate_qdim.rs @@ -6,8 +6,10 @@ use cadcraft_doc::{DimKind, EntityKind, Handle, Prim}; use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::super::machines::{SelOutcome, SelectPhase}; +use super::super::{bad, point_param, point_req, points_param, str_param, targets}; +use super::{MAX_CHAIN, assoc_enabled, dim}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}"; diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 04cbda225..479b7873a 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -17,7 +17,8 @@ use serde_json::{Value, json}; use super::helpers::{line, v3}; use super::machines::{SelOutcome, SelectPhase, number}; use super::modify::transform_entities; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, find_command, point_param, point_req, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod edit; From 004287d91908ae304c665b605c5c6d06df762a3e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 18:18:33 -0400 Subject: [PATCH 75/91] refactor: explicit imports instead of wildcards (S2208, batch_03) (#2040) * refactor: replace wildcard imports with explicit ones (S2208, batch_03) * refactor: replace wildcard imports with explicit ones (S2208, batch_03) * Format imports so the CI fmt gate passes --- crates/engine/src/cmd/file/compare.rs | 3 ++- crates/engine/src/cmd/file/pgp.rs | 3 ++- crates/engine/src/cmd/file/template.rs | 2 +- crates/engine/src/cmd/gripcmds.rs | 3 ++- crates/engine/src/cmd/groups.rs | 3 ++- crates/engine/src/cmd/groups/dash.rs | 3 ++- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 3b6837342..295b472b1 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -12,7 +12,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; -use super::super::*; +use super::super::{CommandSpec, bad, str_param}; + use super::{base64_decode, io}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/file/pgp.rs b/crates/engine/src/cmd/file/pgp.rs index e502ad80a..515feac7f 100644 --- a/crates/engine/src/cmd/file/pgp.rs +++ b/crates/engine/src/cmd/file/pgp.rs @@ -12,8 +12,9 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; use serde_json::{Value, json}; +use super::super::always; use super::super::helpers::support_text; -use super::*; +use super::{CommandSpec, Result, Session, bad, bool_or, find_command}; /// Most user aliases held. pub const MAX_ALIASES: usize = 10_000; diff --git a/crates/engine/src/cmd/file/template.rs b/crates/engine/src/cmd/file/template.rs index f5b29e450..49ce172d0 100644 --- a/crates/engine/src/cmd/file/template.rs +++ b/crates/engine/src/cmd/file/template.rs @@ -10,7 +10,7 @@ use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; use cadcraft_doc::Drawing; use serde_json::{Value, json}; -use super::*; +use super::{Result, Session, bad, io}; /// The default template (profile setting, process-wide like FONTALT): empty = none. fn default_template() -> MutexGuard<'static, String> { diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs index cf637d9c8..a7ba2cdd6 100644 --- a/crates/engine/src/cmd/gripcmds.rs +++ b/crates/engine/src/cmd/gripcmds.rs @@ -5,7 +5,8 @@ use cadcraft_geom::{Mat3, Vec2}; use serde_json::{Value, json}; use super::curves; -use super::*; +use super::{CommandSpec, bad, bool_or, point_param, point_req, str_param, targets}; + use crate::grips::{GripAction, GripMode, grip_action, mode_matrix, stretch_grip}; use crate::{EngineError, Result, Session}; diff --git a/crates/engine/src/cmd/groups.rs b/crates/engine/src/cmd/groups.rs index 42addaef6..85b859f90 100644 --- a/crates/engine/src/cmd/groups.rs +++ b/crates/engine/src/cmd/groups.rs @@ -11,7 +11,8 @@ use cadcraft_doc::{Drawing, Group, Handle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::{CommandSpec, bad, bool_or, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; mod dash; diff --git a/crates/engine/src/cmd/groups/dash.rs b/crates/engine/src/cmd/groups/dash.rs index 902121e33..b131f7ff6 100644 --- a/crates/engine/src/cmd/groups/dash.rs +++ b/crates/engine/src/cmd/groups/dash.rs @@ -7,7 +7,8 @@ use serde_json::{Value, json}; use super::super::machines::{SelOutcome, SelectPhase, number}; use super::super::qselect::wildcard; -use super::super::*; +use super::super::{CommandSpec, bad, bool_or, str_param, targets}; + use super::{add_members, create, echo_list, find, remove_members, rename, ungroup, valid_name}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; From 66ecce06f56e9fb2a6131a76dac3223c3f4ecb48 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:19:58 -0400 Subject: [PATCH 76/91] refactor: explicit imports instead of wildcards (S2208, batch_01) (#2038) * refactor: replace wildcard imports with explicit ones (S2208, batch_01) * refactor: replace wildcard imports with explicit ones (S2208, batch_01) * Format imports so the CI fmt gate passes --- crates/engine/src/cmd/array/edit.rs | 3 ++- crates/engine/src/cmd/audit.rs | 4 +++- crates/engine/src/cmd/blocks.rs | 4 +++- crates/engine/src/cmd/constraints.rs | 3 ++- crates/engine/src/cmd/draw.rs | 9 +++++++-- crates/engine/src/cmd/draw2.rs | 6 ++++-- crates/engine/src/cmd/draw2/breakline.rs | 2 +- crates/engine/src/cmd/draw2/mline.rs | 4 +++- 8 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/array/edit.rs b/crates/engine/src/cmd/array/edit.rs index 806a75591..21bf6b132 100644 --- a/crates/engine/src/cmd/array/edit.rs +++ b/crates/engine/src/cmd/array/edit.rs @@ -16,7 +16,8 @@ use serde_json::{Value, json}; use super::super::helpers::v3; use super::super::machines::{SelOutcome, SelectPhase, number}; -use super::super::*; +use super::super::{CommandSpec, bad, bool_or, point_param, targets}; + use super::{KEYWORDS, angle, check_size, count, fmt, nesting, other, yes, yes_no}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/audit.rs b/crates/engine/src/cmd/audit.rs index 0a6805eb5..fa3cc2f98 100644 --- a/crates/engine/src/cmd/audit.rs +++ b/crates/engine/src/cmd/audit.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, bool_or, file, str_param}; + +use super::always; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index c9c9abc8f..15d75da14 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -12,7 +12,9 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, point_req, point_value, str_param, targets}; + +use super::has_doc; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod attedit; diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index 41ded6d36..b37ff9882 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -14,7 +14,8 @@ use cadcraft_geom::{Line, Vec2}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_value, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; macro_rules! gc { diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index ce8c8f7d1..6f4b77c84 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -5,10 +5,15 @@ use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign}; use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc}; use serde_json::{Value, json}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, polygon_from_edge, polygon_typed_radius_point, polygon_vertices, v3}; + use super::machines::number; use super::pline_opts::{Outcome, PlineAsk}; -use super::*; +use super::{ + CommandSpec, bad, bool_or, distinct_points, f64_or, f64_req, find_command, point_param, point_req, point_value, points_param, size_param, + str_param, +}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; /// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 60abbf74e..3fc4e85f2 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -7,9 +7,11 @@ use cadcraft_geom::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Spline, TA use serde_json::{Value, json}; use super::curves::{self, Chain, MAX_GEN, TanObj}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, rect_vertices, rect_with_corners, v3}; + use super::machines::number; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, point_value, points_param, size_param, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub(crate) mod breakline; diff --git a/crates/engine/src/cmd/draw2/breakline.rs b/crates/engine/src/cmd/draw2/breakline.rs index 37e20a492..e1b601dc7 100644 --- a/crates/engine/src/cmd/draw2/breakline.rs +++ b/crates/engine/src/cmd/draw2/breakline.rs @@ -9,9 +9,9 @@ use cadcraft_doc::{Drawing, EntityKind}; use cadcraft_geom::{PolyVertex, Vec2}; use serde_json::Value; +use super::super::machines::number; use super::added; use super::curves::MAX_GEN; -use super::machines::number; use super::*; use crate::cmd::modify2::as_lwpoly; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/draw2/mline.rs b/crates/engine/src/cmd/draw2/mline.rs index 2f1b7edc6..4ffbf609a 100644 --- a/crates/engine/src/cmd/draw2/mline.rs +++ b/crates/engine/src/cmd/draw2/mline.rs @@ -9,8 +9,10 @@ use cadcraft_doc::{ use cadcraft_geom::{Line, Vec2, line_line_infinite}; use serde_json::{Value, json}; +use super::super::distinct_points; +use super::super::machines::number; +use super::super::ok; use super::curves::{self, MAX_GEN}; -use super::machines::number; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; From cb8b1709e911a328fb510a44513e22613cbc697c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:21:08 -0400 Subject: [PATCH 77/91] refactor: explicit imports instead of wildcards (S2208, batch_05) (#2042) * refactor: replace wildcard imports with explicit ones (S2208, batch_05) * refactor: replace wildcard imports with explicit ones (S2208, batch_05) * Format imports so the CI fmt gate passes --- crates/engine/src/cmd/inquiry/regions.rs | 3 ++- crates/engine/src/cmd/inquiry/shapes.rs | 4 +++- crates/engine/src/cmd/inquiry/usage.rs | 3 ++- crates/engine/src/cmd/layer.rs | 3 ++- crates/engine/src/cmd/layout.rs | 1 + crates/engine/src/cmd/layout/plotstyle.rs | 3 ++- 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 751635730..9e6b11a49 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -11,7 +11,8 @@ use cadcraft_geom::{Line, Vec2, line_line, point_in_polygon}; use serde_json::{Value, json}; use super::super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::super::*; +use super::super::{CommandSpec, bad, targets}; + use super::SelfCheck; use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/inquiry/shapes.rs b/crates/engine/src/cmd/inquiry/shapes.rs index b48ffeb15..1591b67b0 100644 --- a/crates/engine/src/cmd/inquiry/shapes.rs +++ b/crates/engine/src/cmd/inquiry/shapes.rs @@ -6,7 +6,9 @@ use cadcraft_doc::{Entity, EntityKind}; use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments}; use serde_json::{Value, json}; -use super::super::*; +use super::super::bad; + +use super::super::point_value; use super::{SelfCheck, area_of, object_area}; use crate::{Result, Session}; diff --git a/crates/engine/src/cmd/inquiry/usage.rs b/crates/engine/src/cmd/inquiry/usage.rs index a2945aa4a..92705c4f9 100644 --- a/crates/engine/src/cmd/inquiry/usage.rs +++ b/crates/engine/src/cmd/inquiry/usage.rs @@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet}; use cadcraft_doc::{Drawing, Entity, EntityKind}; use serde_json::{Value, json}; +use super::super::bad; use super::super::blocks::purge; -use super::super::*; + use crate::{Result, Session}; const CMD: &str = "usage"; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 4f0a58639..582a93635 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -7,7 +7,8 @@ use cadcraft_doc::{Layer, Lineweight}; use serde_json::{Value, json}; use super::machines::SelectRun; -use super::*; +use super::{CommandSpec, bad, bool_or, ok, str_param, targets}; + use crate::{Result, Session}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a6150ed89..924aed5ee 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use super::file::{base64_encode, io}; use super::*; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; use viewports::MviewM; diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs index b92b15826..daf39ba8d 100644 --- a/crates/engine/src/cmd/layout/plotstyle.rs +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -8,7 +8,8 @@ use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable}; use serde_json::{Value, json}; -use super::*; +use super::super::targets; +use super::{CommandSpec, Result, Session, bad, str_param}; pub fn specs() -> Vec { vec![ From 3d8301b96598e0f3897134b245b8ae79a9d0ada3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:21:13 -0400 Subject: [PATCH 78/91] refactor: explicit imports instead of wildcards (S2208, batch_06) (#2043) * refactor: replace wildcard imports with explicit ones (S2208, batch_06) * Restore imports that the cadcraft batch dropped so the engine compiles The batch replaced glob imports with explicit lists that missed names the files still use, and pointed some sub-module imports at the wrong level. Restore the required imports and the test module globs. * Format pedit_vertex imports so the CI fmt gate passes --- crates/engine/src/cmd/mleader_edit.rs | 3 ++- crates/engine/src/cmd/modify.rs | 6 ++++-- crates/engine/src/cmd/modify2.rs | 6 ++++-- crates/engine/src/cmd/modify2/pedit_vertex.rs | 5 ++++- crates/engine/src/cmd/modify2/splinedit.rs | 6 +++++- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/mleader_edit.rs b/crates/engine/src/cmd/mleader_edit.rs index 1416d71ab..e8e50bbf8 100644 --- a/crates/engine/src/cmd/mleader_edit.rs +++ b/crates/engine/src/cmd/mleader_edit.rs @@ -9,7 +9,8 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::{SelOutcome, SelectPhase}; -use super::*; +use super::{CommandSpec, bad, point_req, points_param, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most arrowheads added by one call, and most multileaders aligned / collected by one call. diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 6c350e407..c25eb50c8 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -7,10 +7,12 @@ use cadcraft_geom::{ }; use serde_json::{Value, json}; -use super::helpers::*; +use super::helpers::{arc, circle, line, lwpoly, v3}; + use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets}; + use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index e6493e526..27327b13b 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -9,9 +9,11 @@ use cadcraft_geom::{ use serde_json::{Value, json}; use super::curves::{self, Chain, MAX_GEN, TanObj}; -use super::helpers::*; +use super::helpers::{arc, line, lwpoly, v3}; + use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, str_param, targets}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; mod curve_edits; diff --git a/crates/engine/src/cmd/modify2/pedit_vertex.rs b/crates/engine/src/cmd/modify2/pedit_vertex.rs index be0d5d383..70ca06c2a 100644 --- a/crates/engine/src/cmd/modify2/pedit_vertex.rs +++ b/crates/engine/src/cmd/modify2/pedit_vertex.rs @@ -1,7 +1,10 @@ //! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions //! that Fit honours, and Regen. -use super::*; +use super::super::point_value; +use super::{ + EntityKind, Handle, Input, LwPolyline, PPhase, PeditM, PolyVertex, Result, Session, Value, Vec2, bad, curves, json, other, push_entity, set_kind, +}; fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> { let n = pl.vertices.len(); diff --git a/crates/engine/src/cmd/modify2/splinedit.rs b/crates/engine/src/cmd/modify2/splinedit.rs index 212433ea7..f5ba767fb 100644 --- a/crates/engine/src/cmd/modify2/splinedit.rs +++ b/crates/engine/src/cmd/modify2/splinedit.rs @@ -6,7 +6,11 @@ //! parametrisation, end tangents and fit tolerance. Control-point edits change the curve //! directly and drop the fit data, as the curve no longer follows it. -use super::*; +use super::super::{point_value, points_param}; +use super::{ + Accept, Entity, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space, + Spline, Step, Value, Vec2, bad, bool_or, curves, f64_req, json, line, number, other, point_req, set_kind, str_param, targets, +}; /// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters). pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result { From 660a7881d153caec57e084be93239d83d618da63 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:21:17 -0400 Subject: [PATCH 79/91] refactor: explicit imports instead of wildcards (S2208, batch_07) (#2044) * refactor: replace wildcard imports with explicit ones (S2208, batch_07) * Restore imports that the cadcraft batch dropped so the engine compiles Explicit import lists missed names the files still use. Restore them and the test-module globs. --- crates/engine/src/cmd/overkill.rs | 3 ++- crates/engine/src/cmd/plotters.rs | 3 ++- crates/engine/src/cmd/props.rs | 6 +++--- crates/engine/src/cmd/props/linfile.rs | 3 ++- crates/engine/src/cmd/props/matchprop.rs | 3 ++- crates/engine/src/cmd/qselect.rs | 6 +++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/overkill.rs b/crates/engine/src/cmd/overkill.rs index 92fc2ab7e..abfdff2eb 100644 --- a/crates/engine/src/cmd/overkill.rs +++ b/crates/engine/src/cmd/overkill.rs @@ -13,7 +13,8 @@ use cadcraft_geom::{TAU, Vec2, Vec3, ccw_sweep, norm_angle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, number}; -use super::*; +use super::{CommandSpec, bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index a3e1b59f1..370f03518 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -14,7 +14,8 @@ use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfi use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, Result, always, bad, has_doc, point_req, str_param}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step}; /// Where plotter configuration files live. Names are file names ending in diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 0150c3390..f9fbf2969 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -10,7 +10,8 @@ use cadcraft_doc::{EntityKind, Lineweight, Transparency}; use cadcraft_geom::Vec3; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, bool_or, f64_req, find_command, ok, point_req, point_value, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { @@ -1177,10 +1178,9 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use super::*; use serde_json::json; - use crate::Session; - #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props/linfile.rs b/crates/engine/src/cmd/props/linfile.rs index 2290051c3..b8fd023f2 100644 --- a/crates/engine/src/cmd/props/linfile.rs +++ b/crates/engine/src/cmd/props/linfile.rs @@ -7,7 +7,8 @@ use serde_json::{Value, json}; use super::super::helpers::support_text; use super::super::qselect::wildcard; -use super::*; +use super::{Result, Session, bad, bool_or, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Step}; /// Names no file may define: they are not linetype definitions. diff --git a/crates/engine/src/cmd/props/matchprop.rs b/crates/engine/src/cmd/props/matchprop.rs index a53f6a5ef..31f4e4951 100644 --- a/crates/engine/src/cmd/props/matchprop.rs +++ b/crates/engine/src/cmd/props/matchprop.rs @@ -6,7 +6,8 @@ use cadcraft_doc::{Entity, EntityKind, Handle}; use serde_json::{Value, json}; -use super::super::*; +use super::super::{bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Which properties MATCHPROP copies (its Settings). diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs index 808952e7a..2c5330371 100644 --- a/crates/engine/src/cmd/qselect.rs +++ b/crates/engine/src/cmd/qselect.rs @@ -5,7 +5,8 @@ use cadcraft_color::Color; use cadcraft_doc::{Drawing, Entity, Handle}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, str_param}; + use crate::{Result, Session}; pub fn specs() -> Vec { @@ -320,9 +321,8 @@ fn run_info(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { - use serde_json::json; - use super::*; + use serde_json::json; fn session() -> Session { let mut s = Session::new(); From 055b06a2f47e96a493c58c9034416a9e8c838d6c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:21:22 -0400 Subject: [PATCH 80/91] refactor: explicit imports instead of wildcards (S2208, batch_08) (#2045) * refactor: replace wildcard imports with explicit ones (S2208, batch_08) * Restore imports that the cadcraft batch dropped so the engine compiles Explicit import lists missed names the files still use. Restore them and the test-module globs. --- crates/engine/src/cmd/selecttools.rs | 3 ++- crates/engine/src/cmd/settings.rs | 3 ++- crates/engine/src/cmd/table.rs | 3 ++- crates/engine/src/cmd/tolerance.rs | 3 ++- crates/engine/src/cmd/utility.rs | 3 ++- crates/engine/src/cmd/view.rs | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/selecttools.rs b/crates/engine/src/cmd/selecttools.rs index eef98ffdd..1f742bb11 100644 --- a/crates/engine/src/cmd/selecttools.rs +++ b/crates/engine/src/cmd/selecttools.rs @@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, bad, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Most objects one call looks at (a hostile drawing can be huge). diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index 014e130eb..1cd6cbfaa 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -2,7 +2,8 @@ use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, point_param, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub(super) mod iso; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index f73222866..8f7e8f1dd 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -12,7 +12,8 @@ use cadcraft_geom::Vec2; use serde_json::{Value, json}; use super::helpers::v3; -use super::*; +use super::{CommandSpec, bad, point_req, size_param, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Caps for hostile parameters. diff --git a/crates/engine/src/cmd/tolerance.rs b/crates/engine/src/cmd/tolerance.rs index 1a33a5670..b4edfe0f9 100644 --- a/crates/engine/src/cmd/tolerance.rs +++ b/crates/engine/src/cmd/tolerance.rs @@ -5,7 +5,8 @@ use cadcraft_geom::{PolyVertex, Vec2}; use serde_json::{Value, json}; use super::helpers::{line, lwpoly, v3}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_req, str_param}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index c73a7077d..97678ae24 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -4,7 +4,8 @@ use cadcraft_doc::{EntityKind, Handle}; use cadcraft_geom::{Segment, Vec2}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, always, bad, bool_or, point_param, points_param, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index e6051a6e9..4586cd988 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -6,7 +6,8 @@ use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; use super::machines::{SelectRun, number}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_req, str_param, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View}; mod named_views; From f24af47ffbe33478d27571df521cb8d8499ba9cb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:21:26 -0400 Subject: [PATCH 81/91] refactor: cut helper signatures to 7 parameters (S107) (#2047) * refactor: cut four helper signatures to 7 parameters (S107) * fix: item_json params * fix: keep the eight-argument dim calls to seven after rebase --- crates/engine/src/cmd/annotate.rs | 47 +++++++++---------- crates/engine/src/cmd/annotate_dimedit.rs | 2 +- crates/engine/src/cmd/annotate_qdim.rs | 9 ++-- crates/engine/src/cmd/draw2.rs | 9 ++-- crates/engine/src/cmd/inquiry/usage.rs | 14 +++--- crates/engine/src/cmd/modify2/fillet_pline.rs | 5 +- crates/render/src/hatch.rs | 4 +- 7 files changed, 43 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index d371d85af..6fdd0869d 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -120,7 +120,7 @@ fn style(s: &Session) -> String { s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()) } -fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p16: Vec2, text: &str) -> EntityKind { +fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind { EntityKind::Dimension(Dimension { kind, defpt: v3(defpt), @@ -128,7 +128,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p13: v3(p13), p14: v3(p14), p15: v3(p15), - p16: v3(p16), + p16: v3(Vec2::ZERO), text: text.into(), style: style(s), measurement: 0.0, @@ -205,7 +205,10 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option Result { }; let at = point_req("dimlinear", p, "at")?; let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians); - let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or("")); + let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or("")); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -233,7 +236,7 @@ fn run_aligned(s: &mut Session, p: &Value) -> Result { let a = point_req("dimaligned", p, "p1")?; let b = point_req("dimaligned", p, "p2")?; let at = point_req("dimaligned", p, "at")?; - let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or("")); + let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or("")); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -261,9 +264,9 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { return Err(bad(id, "`point` must differ from `center`")); } let k = if diameter { - dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "") } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") }; let k = match at { Some(at) => text_at(k, at), @@ -308,7 +311,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("dimangular", "object is not an arc")), }; let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); - let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""); + let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, ""); return Ok(json!({ "handle": add_dim(s, k)?.hex() })); } let v = point_req("dimangular", p, "vertex")?; @@ -318,7 +321,7 @@ fn run_angular(s: &mut Session, p: &Value) -> Result { if v.near(a, 1e-12) || v.near(b, 1e-12) { return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`")); } - let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""); + let k = dim(s, DimKind::Angular3P, at, a, b, v, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -330,7 +333,7 @@ fn run_arclen(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("dimarc", "object is not an arc")), }; let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); - let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, Vec2::ZERO, ""); + let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -338,7 +341,7 @@ fn run_ordinate(s: &mut Session, p: &Value) -> Result { let f = point_req("dimordinate", p, "feature")?; let l = point_req("dimordinate", p, "leader")?; let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs()); - let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""); + let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } @@ -1060,7 +1063,7 @@ impl Interactive for LinearM { } (_, Input::Point(at)) => { let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; - let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, "")); + let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, "")); add_dim(s, k)?; if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default(); @@ -1087,7 +1090,7 @@ impl Interactive for LinearM { fn preview(&self, s: &Session, c: Vec2) -> Vec { match self.pts.as_slice() { [a] => vec![super::helpers::line(*a, c)], - [a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))], + [a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))], _ => Vec::new(), } } @@ -1106,9 +1109,9 @@ impl RadialM { fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind { let pt = c + (at - c).normalized() * r; let k = self.text.apply(if self.diameter { - dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "") } else { - dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") + dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "") }); text_at(k, at) } @@ -1241,7 +1244,7 @@ impl Interactive for AngularM { ([h1, h2], None) => angular_from_lines(s, *h1, *h2, at), _ => None, }; - let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "")); + let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, "")); add_dim(s, self.text.apply(k))?; Ok(Step::Done) } @@ -1307,9 +1310,7 @@ impl Interactive for AngularM { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.geometry() - .map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))]) - .unwrap_or_default() + self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default() } } @@ -1385,7 +1386,7 @@ impl Interactive for ArcLenM { Ok(Step::Continue) } (Some(g), Input::Point(at)) => { - add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?; + add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?; Ok(Step::Done) } (Some(_), Input::Keyword(k)) => { @@ -1404,9 +1405,7 @@ impl Interactive for ArcLenM { if self.partial.is_some() { return Vec::new(); } - self.arc - .map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))]) - .unwrap_or_default() + self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default() } } @@ -1421,7 +1420,7 @@ struct OrdinateM { impl OrdinateM { fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind { let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs()); - self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "")) + self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, "")) } } diff --git a/crates/engine/src/cmd/annotate_dimedit.rs b/crates/engine/src/cmd/annotate_dimedit.rs index 87d1c2dca..563b67de1 100644 --- a/crates/engine/src/cmd/annotate_dimedit.rs +++ b/crates/engine/src/cmd/annotate_dimedit.rs @@ -283,7 +283,7 @@ fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option } let tip = c + dir * r; let jog = jog.unwrap_or_else(|| center.mid(tip)); - let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text); + let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, text); if let EntityKind::Dimension(dm) = &mut k { dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) }); if at.dist(c) > r + 1e-9 { diff --git a/crates/engine/src/cmd/annotate_qdim.rs b/crates/engine/src/cmd/annotate_qdim.rs index 06a7b4956..34b327e4e 100644 --- a/crates/engine/src/cmd/annotate_qdim.rs +++ b/crates/engine/src/cmd/annotate_qdim.rs @@ -96,9 +96,9 @@ fn plan( let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir }; let p = *c + dir * *r; out.push(if mode == Mode::Diameter { - dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "") + dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, "") } else { - dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "") + dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, "") }); } if out.is_empty() { @@ -131,7 +131,7 @@ fn plan( .iter() .map(|p| { let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) }; - dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "") + dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, "") }) .collect()); } @@ -147,8 +147,7 @@ fn plan( let mid = bounds.center(); let out_dir = if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) }; - let lin = - |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, ""); + let lin = |a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, ""); Ok(match mode { Mode::Baseline => { // From the datum (the point nearest it, by default the first) to every other point. diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 3fc4e85f2..c174994c1 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1574,7 +1574,7 @@ impl Interactive for XlineM2 { // RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness // ===================================================================================== -fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind { +fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind { let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer); let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect(); let mut k = lwpoly(vs, true); @@ -1633,7 +1633,7 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { return Err(bad("rectang", "rectangle has no area")); } let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a }; - let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); + let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); let hd = s.add_entity(k)?; // A rectangle has its own thickness option and does not take THICKNESS. let th = f64_or(p, "thickness", 0.0); @@ -1685,7 +1685,8 @@ impl RectM2 { return Err(other("The rectangle has no area.")); } let first = self.origin(w, h).unwrap_or(a); - let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; + let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; + // A rectangle has its own thickness option and does not take THICKNESS. let t = self.thickness; s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; Ok(Step::Done) @@ -1863,7 +1864,7 @@ impl Interactive for RectM2 { } match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) { Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => { - vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] + vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] } _ => Vec::new(), } diff --git a/crates/engine/src/cmd/inquiry/usage.rs b/crates/engine/src/cmd/inquiry/usage.rs index 92705c4f9..7bf6a2d17 100644 --- a/crates/engine/src/cmd/inquiry/usage.rs +++ b/crates/engine/src/cmd/inquiry/usage.rs @@ -281,11 +281,9 @@ impl Unused { fn item_json( c: Category, name: &str, - entities: usize, - block_entities: usize, + (entities, block_entities): (usize, usize), references: usize, - used: bool, - empty: bool, + (used, empty): (bool, bool), current: Option, unused: &Unused, ) -> Value { @@ -315,13 +313,13 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default(); let uses = e.saturating_add(b); let cur = l.name.eq_ignore_ascii_case(¤t_layer); - out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::Layer, &l.name, (e, b), 0, (uses > 0, uses == 0), Some(cur), unused)); } for b in d.blocks.values() { let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default(); // A definition is used when a reference to it is reachable, as PURGE counts it. let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase()); - out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused)); + out.push(item_json(Category::Block, &b.name, (e, n), 0, (used, e.saturating_add(n) == 0), None, unused)); } let current_style = d.header.str("TEXTSTYLE", "Standard"); for t in &d.text_styles { @@ -329,7 +327,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0); let uses = e.saturating_add(b).saturating_add(r); let cur = t.name.eq_ignore_ascii_case(¤t_style); - out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::TextStyle, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused)); } let current_linetype = d.header.str("CELTYPE", "ByLayer"); for t in &d.linetypes { @@ -337,7 +335,7 @@ fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec { let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0); let uses = e.saturating_add(b).saturating_add(r); let cur = t.name.eq_ignore_ascii_case(¤t_linetype); - out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused)); + out.push(item_json(Category::Linetype, &t.name, (e, b), r, (uses > 0, uses == 0), Some(cur), unused)); } out } diff --git a/crates/engine/src/cmd/modify2/fillet_pline.rs b/crates/engine/src/cmd/modify2/fillet_pline.rs index d6e7fa840..6ccbf8483 100644 --- a/crates/engine/src/cmd/modify2/fillet_pline.rs +++ b/crates/engine/src/cmd/modify2/fillet_pline.rs @@ -80,8 +80,7 @@ fn same_polyline( s: &mut Session, h: Handle, pl: &LwPolyline, - p1: Vec2, - p2: Vec2, + (p1, p2): (Vec2, Vec2), r: f64, chamfer: Option, trim: bool, @@ -223,7 +222,7 @@ pub(crate) fn fillet_pline( _ => None, }; let made = match (&e1.kind, &e2.kind) { - (EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, p1, p2, r, chamfer, trim)?, + (EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, (p1, p2), r, chamfer, trim)?, (EntityKind::LwPolyline(pl), k) if as_line(k).is_some() => { let ln = as_line(k).ok_or_else(|| other("Select a line."))?; polyline_and_line(s, (h1, pl, p1), (h2, ln, p2), true, r, chamfer, trim)? diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index dd83a71a2..f48b6344e 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -528,14 +528,14 @@ impl Family<'_> { if solid_line { out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]); } else { - self.dash(x0, x1, y, offset, pat_len, out, work); + self.dash((x0, x1), y, offset, pat_len, out, work); } } } } /// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`. - fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { + fn dash(&self, (x0, x1): (f64, f64), y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { let start_rep = ((x0 - offset) / pat_len).floor(); let mut x = offset + start_rep * pat_len; let mut guard = 0; From 1b6861698cc39c24e9a29eebfd487a0f99bbca95 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:22:31 -0400 Subject: [PATCH 82/91] refactor: cut test helper and two DXF writer closures to 7 parameters (S107) (#2048) --- crates/io/src/dxf_write.rs | 22 +++++++++++----------- crates/io/tests/pdf_text.rs | 28 ++++++++++++++-------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 06c8b648f..385e07580 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2373,7 +2373,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { // ---------------- BLOCKS ---------------- w.s(0, "SECTION"); w.s(2, "BLOCKS"); - let block = |w: &mut W, name: &str, desc: &str, brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { + let block = |w: &mut W, (name, desc): (&str, &str), brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { let bh = w.h(); w.s(0, "BLOCK"); w.s(5, bh); @@ -2406,16 +2406,16 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(8, "0"); w.s(100, "AcDbBlockEnd"); }; - block(&mut w, "*Model_Space", "", &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); + block(&mut w, ("*Model_Space", ""), &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); for (i, (n, h, l)) in ps_brs.iter().enumerate() { if i == 0 { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut std::iter::empty(), true); } else { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut sheet_first(l).into_iter(), true); } } for (n, h, b) in &user_blocks { - block(&mut w, n, &b.description, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); + block(&mut w, (n, &b.description), h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); } // Generated geometry entities (dimension, arrowhead and table blocks) need handles. for (_, _, ents) in dim_defs.iter_mut().chain(arrow_defs.iter_mut()).chain(table_defs.iter_mut()) { @@ -2425,13 +2425,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { } } for (n, h, ents) in &dim_defs { - block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); } for (n, h, ents) in &arrow_defs { - block(&mut w, n, "", h, Vec3::ZERO, 0, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 0, &mut ents.iter(), false); } for (n, h, ents) in &table_defs { - block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, (n, ""), h, Vec3::ZERO, 1, &mut ents.iter(), false); } w.s(0, "ENDSEC"); @@ -2612,7 +2612,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(3, &l.name); w.s(350, layout_handles.get(i).cloned().unwrap_or_default()); } - let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { + let layout_obj = |w: &mut W, h: &str, (name, tab): (&str, i64), brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units // per 143 drawing units). let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; @@ -2697,10 +2697,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { // plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means // nothing for model space: another program's Plot dialog then shows no plot area or scale. let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() }; - layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &model_page, None); + layout_obj(&mut w, &model_layout, ("Model", 0), &ms_br, 1, &model_page, None); for (i, (_, brh, l)) in ps_brs.iter().enumerate() { let h = layout_handles.get(i).cloned().unwrap_or_default(); - layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view); + layout_obj(&mut w, &h, (&l.name, i64::from(l.tab_order.max(1))), brh, 1, &l.page, l.view); } // Raster image definitions. w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs index c238b2013..baba82f9d 100644 --- a/crates/io/tests/pdf_text.rs +++ b/crates/io/tests/pdf_text.rs @@ -165,7 +165,7 @@ fn style(d: &mut Drawing, name: &str, font: &str) { d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); } -fn text(d: &mut Drawing, space: &Space, style: &str, at: (f64, f64), value: &str, rotation: f64, width_factor: f64, oblique: f64) { +fn text(d: &mut Drawing, space: &Space, style: &str, at: (f64, f64), value: &str, (rotation, width_factor, oblique): (f64, f64, f64)) { let t = Text { insert: Vec3::new(at.0, at.1, 0.0), align_pt: None, @@ -225,7 +225,7 @@ fn font_files(doc: &Doc) -> usize { #[test] fn truetype_text_is_embedded_as_text() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "ABBA", (0.0, 1.0, 0.0)); let doc = pdf(&d, &Space::Model, json!({})); let page = &doc.pages[0]; // A Type0 font over a CIDFontType2 subset with a ToUnicode map. @@ -260,7 +260,7 @@ fn truetype_text_is_embedded_as_text() { #[test] fn the_subset_is_a_valid_truetype_font() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", (0.0, 1.0, 0.0)); let doc = pdf(&d, &Space::Model, json!({})); let file = doc.pages[0].fonts[0].font_file.clone().expect("an embedded font file"); let face = cadcraft_fonts::ttf::Face::new(std::sync::Arc::new(file.clone()), 0).expect("the subset parses"); @@ -273,9 +273,9 @@ fn the_subset_is_a_valid_truetype_font() { fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { let mut d = drawing(); let quarter = std::f64::consts::FRAC_PI_2; - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", quarter, 1.0, 0.0); - text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", 0.0, 2.0, 0.0); - text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", 0.0, 1.0, 15f64.to_radians()); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "A", (quarter, 1.0, 0.0)); + text(&mut d, &Space::Model, "Real", (0.0, 50.0), "A", (0.0, 2.0, 0.0)); + text(&mut d, &Space::Model, "Real", (0.0, 100.0), "A", (0.0, 1.0, 15f64.to_radians())); let m = matrices(&pdf(&d, &Space::Model, json!({}))); assert_eq!(m.len(), 3, "{m:?}"); // Rotated a quarter turn: the baseline points up, "up" points left. @@ -294,7 +294,7 @@ fn rotation_width_factor_and_obliquing_go_into_the_text_matrix() { #[test] fn a_font_the_writer_cannot_subset_plots_as_outlines() { let mut d = drawing(); - text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Short", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "outlines: {}", p.content()); } @@ -302,16 +302,16 @@ fn a_font_the_writer_cannot_subset_plots_as_outlines() { #[test] fn fonts_that_forbid_embedding_and_the_geometry_option_draw_outlines() { let mut d = drawing(); - text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Locked", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "restricted licence: outlines"); let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({"text": "geometry"})); assert!(font_files(&p) == 0 && pdf_read::count(p.content(), "BT") == 0 && fills(&p) > 0, "text as geometry"); // The stroke font is always geometry. let mut d = drawing(); - text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Standard", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); assert!(pdf_read::count(p.content(), "BT") == 0 && pdf_read::count(p.content(), "S") > 0); } @@ -321,8 +321,8 @@ fn text_seen_through_a_viewport_and_on_layers() { let mut d = drawing(); let paper = Space::Paper("Layout1".into()); // Model: one word well inside the viewport, one cut by its right edge. - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); - text(&mut d, &Space::Model, "Real", (88.0, 0.0), "BA", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); + text(&mut d, &Space::Model, "Real", (88.0, 0.0), "BA", (0.0, 1.0, 0.0)); let vp = Viewport { center: Vec3::new(150.0, 100.0, 0.0), width: 100.0, @@ -337,7 +337,7 @@ fn text_seen_through_a_viewport_and_on_layers() { on: true, }; d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); - text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0); + text(&mut d, &paper, "Real", (20.0, 20.0), "B", (0.0, 1.0, 0.0)); let p = pdf(&d, &paper, json!({})); // "AB" inside, the paper-space "B", and the glyph of "BA" left of the edge: real text. The // glyph the edge cuts stays a (clipped) outline. @@ -353,7 +353,7 @@ fn text_seen_through_a_viewport_and_on_layers() { #[test] fn text_sits_in_its_layers_marked_content() { let mut d = drawing(); - text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", 0.0, 1.0, 0.0); + text(&mut d, &Space::Model, "Real", (0.0, 0.0), "AB", (0.0, 1.0, 0.0)); let p = pdf(&d, &Space::Model, json!({})); let ops = pdf_read::ops(p.content()); let first_bt = ops.iter().position(|o| o.op == "BT").unwrap(); From f56be09993da7c89b957f93c50d55b4b04fc0213 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:37:00 -0400 Subject: [PATCH 83/91] refactor: explicit imports instead of wildcards (S2208, batch_04) (#2041) * refactor: replace wildcard imports with explicit ones (S2208, batch_04) * refactor: replace wildcard imports with explicit ones (S2208, batch_04) * Format imports so the CI fmt gate passes * fix: import f64_req in inquiry.rs --- crates/engine/src/cmd/groups/pick.rs | 4 +++- crates/engine/src/cmd/hatch.rs | 2 ++ crates/engine/src/cmd/hatch/patfile.rs | 2 +- crates/engine/src/cmd/inquiry.rs | 5 ++++- crates/engine/src/cmd/inquiry/count.rs | 3 ++- crates/engine/src/cmd/inquiry/loops.rs | 3 ++- 6 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/groups/pick.rs b/crates/engine/src/cmd/groups/pick.rs index c18ab96ee..7a9833876 100644 --- a/crates/engine/src/cmd/groups/pick.rs +++ b/crates/engine/src/cmd/groups/pick.rs @@ -7,7 +7,9 @@ use std::collections::{HashMap, HashSet}; use cadcraft_doc::Handle; use serde_json::{Value, json}; -use super::super::*; +use super::super::{CommandSpec, bad}; + +use super::super::always; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 8b33cc716..d0221a00a 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -11,11 +11,13 @@ use serde_json::{Value, json}; use super::helpers::lwpoly; use super::*; + use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; mod assoc; mod edit; mod pick; +use super::has_doc; pub(crate) use assoc::update as refill_associative; pub use pick::{HatchPicks, PickMode}; diff --git a/crates/engine/src/cmd/hatch/patfile.rs b/crates/engine/src/cmd/hatch/patfile.rs index 7ffe54724..7a4a727d0 100644 --- a/crates/engine/src/cmd/hatch/patfile.rs +++ b/crates/engine/src/cmd/hatch/patfile.rs @@ -13,7 +13,7 @@ use cadcraft_doc::library::{PatternLine, pattern, standard_patterns}; use serde_json::{Value, json}; use super::super::helpers::support_text; -use super::*; +use super::{Result, Session, bad, bool_or}; /// Most user patterns held at once. pub const MAX_USER_PATTERNS: usize = 4096; diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 49e0424c2..f1bfd8073 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -6,7 +6,8 @@ use cadcraft_doc::{Entity, EntityKind, Handle}; use cadcraft_geom::{Polyline, Vec2, Vec3}; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_param, targets}; + use crate::units::{format_angle, format_distance}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; @@ -1130,6 +1131,8 @@ impl Interactive for AreaM { #[cfg(test)] mod tests { + use cadcraft_doc::Handle; + use super::*; /// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against diff --git a/crates/engine/src/cmd/inquiry/count.rs b/crates/engine/src/cmd/inquiry/count.rs index ea0d5f136..601dce4a1 100644 --- a/crates/engine/src/cmd/inquiry/count.rs +++ b/crates/engine/src/cmd/inquiry/count.rs @@ -9,7 +9,8 @@ use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, use cadcraft_geom::{Mat3, Vec2}; use serde_json::{Value, json}; -use super::super::*; +use super::super::{bad, bool_or, point_req, str_param, targets}; + use super::SelfCheck; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/inquiry/loops.rs b/crates/engine/src/cmd/inquiry/loops.rs index 2a452151f..20c4ac6b8 100644 --- a/crates/engine/src/cmd/inquiry/loops.rs +++ b/crates/engine/src/cmd/inquiry/loops.rs @@ -13,7 +13,8 @@ use cadcraft_doc::{EntityKind, Handle}; use cadcraft_geom::{Arc as GeoArc, EPS, Vec2}; use serde_json::{Value, json}; -use super::super::*; +use super::super::bad; + use crate::{Result, Session}; const CMD: &str = "loops"; From 2521605713d1435f1cd1f3e7d01b5d25c95aac9a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:52:19 -0400 Subject: [PATCH 84/91] refactor: explicit imports instead of wildcards (S2208, batch_02) (#2039) * refactor: replace wildcard imports with explicit ones (S2208, batch_02) * Fix unused and missing imports the batch's CI gate reported Remove two unused imports and restore the json macro and resolve_alias in test modules that the explicit import lists had dropped. * fix: keep the imports the explicit lists still need after the rebase * fix(draw2): import machines, distinct_points and ok from cmd --- crates/engine/src/cmd/draw2/breakline.rs | 3 ++- crates/engine/src/cmd/draw2/mline.rs | 6 +++--- crates/engine/src/cmd/draworder.rs | 3 ++- crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/cmd/exchange.rs | 6 +++++- crates/engine/src/cmd/file.rs | 4 +++- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/draw2/breakline.rs b/crates/engine/src/cmd/draw2/breakline.rs index e1b601dc7..c351b0a88 100644 --- a/crates/engine/src/cmd/draw2/breakline.rs +++ b/crates/engine/src/cmd/draw2/breakline.rs @@ -12,7 +12,8 @@ use serde_json::Value; use super::super::machines::number; use super::added; use super::curves::MAX_GEN; -use super::*; +use super::{CommandSpec, line, lwpoly, point_param, point_req, size_param, str_param}; + use crate::cmd::modify2::as_lwpoly; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/draw2/mline.rs b/crates/engine/src/cmd/draw2/mline.rs index 4ffbf609a..8e6958abb 100644 --- a/crates/engine/src/cmd/draw2/mline.rs +++ b/crates/engine/src/cmd/draw2/mline.rs @@ -9,11 +9,11 @@ use cadcraft_doc::{ use cadcraft_geom::{Line, Vec2, line_line_infinite}; use serde_json::{Value, json}; -use super::super::distinct_points; use super::super::machines::number; -use super::super::ok; +use super::super::{distinct_points, ok}; use super::curves::{self, MAX_GEN}; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_req, points_param, str_param}; + use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/draworder.rs b/crates/engine/src/cmd/draworder.rs index fab673353..8020dc520 100644 --- a/crates/engine/src/cmd/draworder.rs +++ b/crates/engine/src/cmd/draworder.rs @@ -8,7 +8,8 @@ use cadcraft_doc::{EntityKind, Handle}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, EngineError, bad, ok, targets}; + use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dab5d4eb7..3a4c43e73 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -5,7 +5,8 @@ use cadcraft_geom::{Bounds2, Mat3, Vec2}; use serde_json::{Value, json}; use super::machines::{SelOutcome, SelectPhase, SelectRun}; -use super::*; +use super::{CommandSpec, EngineError, bad, bool_or, clipboard, f64_or, has_selection, point_param, point_req, points_param, str_param, targets}; + use crate::undoctl::Control; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index da842c6f0..f9acc909e 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -18,7 +18,8 @@ use cadcraft_render::raster::{RasterOptions, View}; use serde_json::{Value, json}; use super::file::{base64_decode, base64_encode, io}; -use super::*; +use super::{CommandSpec, bad, point_value, str_param}; + use crate::{EngineError, Result, Session}; /// Formats EXPORT writes (the io layer picks the writer from the extension). @@ -557,7 +558,10 @@ mod tests { use cadcraft_geom::Vec3; use super::super::file::{IoHooks, set_io}; + + use super::super::resolve_alias; use super::*; + use serde_json::json; static DELIVERED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 391bf1ca9..95975a4e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,7 +11,8 @@ use cadcraft_doc::Drawing; pub use cadcraft_dxf::Version; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param}; + use crate::{Accept, Input, Prompt, Result, Session, Step}; /// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT. @@ -731,6 +732,7 @@ mod tests { #[cfg(test)] mod pr193_tests { use super::*; + use crate::Session; #[test] fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() { From 47237bd4dd3c75ba5ff0943a4476cdda4bc8d960 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:15:06 -0400 Subject: [PATCH 85/91] chore(deps): safe dependency refresh (#2179) * chore(deps): safe dependency refresh * Hold acadrust at 0.6.6: 0.6.7 reads nothing from a damaged DWG --- Cargo.lock | 240 ++++++++++++++++++++++++++--------------------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 455fa3219..4dd434595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "acadrust" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c" +checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "accesskit" -version = "0.25.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "enumn", "serde", @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", "accesskit_consumer", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.39.1" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", "hashbrown 0.17.1", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "accesskit_ios" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", "accesskit_consumer", @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", "accesskit_consumer", @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -109,9 +109,9 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.35.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", "accesskit_consumer", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.34.1" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", "accesskit_ios", @@ -343,7 +343,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -378,7 +378,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -444,9 +444,9 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "basic-udev" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6" +checksum = "868a8681ed3a19fae3e302c705b1cabf5be53278cc8720a9b82e9805c9ab171d" [[package]] name = "bit-set" @@ -541,7 +541,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -820,9 +820,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -862,9 +862,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -907,9 +907,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -1044,9 +1044,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1162,7 +1162,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1449,9 +1449,9 @@ source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f9 [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1537,9 +1537,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1622,7 +1622,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1673,7 +1673,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2060,9 +2060,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -2074,9 +2074,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -2087,9 +2087,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2101,15 +2101,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2122,15 +2122,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2429,7 +2429,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2458,9 +2458,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2479,9 +2479,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lyon_geom" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" dependencies = [ "arrayvec", "euclid", @@ -2517,9 +2517,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -3076,9 +3076,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", @@ -3102,9 +3102,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3580,9 +3580,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3894,7 +3894,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3918,14 +3918,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -4032,9 +4032,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4180,9 +4180,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4197,7 +4197,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4278,7 +4278,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4353,9 +4353,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -4369,18 +4369,18 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -4393,18 +4393,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -4414,18 +4414,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tracing" @@ -4522,9 +4522,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -4614,9 +4614,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4734,7 +4734,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4904,9 +4904,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5533,9 +5533,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5550,7 +5550,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5622,7 +5622,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5630,9 +5630,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -5662,18 +5662,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.61" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -5697,7 +5697,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5720,9 +5720,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -5731,20 +5731,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zip" -version = "9.1.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" dependencies = [ "crc32fast", "flate2", @@ -5798,9 +5798,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -5813,14 +5813,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -5833,7 +5833,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] From 21c48cc05b85ebcd602b93659946ccd51802377a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:43:04 -0400 Subject: [PATCH 86/91] refactor(io): split complex PDF marks and DXF readers into helpers (#2185) Pure extraction, no behaviour change: same order of side effects, same early returns and error values. Clears cognitive-complexity findings on pdf::marks, dxf_read::read_budgeted, parse_entities, entity_with_table_budget and Objects::apply (S3776). --- crates/io/src/dxf_read.rs | 722 +++++++++++++++++++++----------------- crates/io/src/pdf.rs | 194 +++++----- 2 files changed, 506 insertions(+), 410 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 4c797ed9e..4c19a1184 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -249,6 +249,130 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { } /// Parse one entity while charging materialized table cells to the caller's shared import budget. +/// An ARC, with its angles run the other way in a mirrored OCS. +fn arc(c: &Common, t: &T) -> Arc { + let mut a = Arc { center: ocs(c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; + if c.extrusion.z < 0.0 { + // Mirrored OCS: angles run the other way. + let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); + a.start = cadcraft_geom::norm_angle(s); + a.end = cadcraft_geom::norm_angle(e); + } + a +} + +fn ellipse(c: &Common, t: &T) -> Ellipse { + let mut e = Ellipse { + center: t.p(10), + major: t.p(11), + ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), + start: t.fd(41, 0.0), + end: t.fd(42, std::f64::consts::TAU), + }; + if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { + // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) + // it points the other way, so parameter t lands where -t does in the XY plane. A full + // ellipse is symmetric and stays as it is. + let (s, e2) = (-e.end, -e.start); + e.start = cadcraft_geom::norm_angle(s); + e.end = cadcraft_geom::norm_angle(e2); + } + e +} + +/// A LWPOLYLINE, or the region CADCraft wrote as one. +fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option { + let mut verts = Vec::new(); + let mut cur: Option = None; + for tg in tags { + match tg.code { + 10 => { + if let Some(v) = cur.take() { + verts.push(v); + } + cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); + } + 20 => { + if let Some(v) = cur.as_mut() { + v.p.y = tg.f64(); + } + } + 40 => { + if let Some(v) = cur.as_mut() { + v.start_width = tg.f64(); + } + } + 41 => { + if let Some(v) = cur.as_mut() { + v.end_width = tg.f64(); + } + } + 42 => { + if let Some(v) = cur.as_mut() { + v.bulge = tg.f64(); + } + } + _ => {} + } + } + verts.extend(cur); + let flags = t.i(70).unwrap_or(0); + if c.extrusion.z < 0.0 { + for v in &mut verts { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + } + let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 }; + // A region CADCraft wrote as polylines (`dxf_region`). + crate::dxf_region::from_polyline(tags, pl) +} + +fn spline(t: &T) -> Spline { + let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; + let knots = t.all_f(40); + let control = t.pts(10); + let weights = t.all_f(41); + let fit = t.pts(11); + let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); + let fit_opts = + cadcraft_geom::FitOptions { knots: Default::default(), start_tangent: tangent(12), end_tangent: tangent(13), tolerance: t.fd(44, 0.0) } + .sanitized(); + let closed = t.i(70).unwrap_or(0) & 1 != 0; + let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; + if !sp.is_valid() && fit.len() >= 2 { + sp = Spline::fit_with(&fit, sp.closed, fit_opts); + } else if !sp.is_valid() && sp.control.len() >= 2 { + sp = Spline::from_control(sp.control.clone(), degree); + } else if fit.len() >= 3 { + // DXF keeps the knots, not how they were spaced: recover it for later refits. + sp.fit_opts.knots = sp.infer_knot_param(); + } + sp +} + +/// An ATTDEF, whose multiline text is embedded MTEXT after its own groups. +fn attdef(tags: &[Tag], c: &Common) -> Attrib { + // A multiline definition has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(tags); + let multiline = multiline_attrib(embedded, c); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: own.i(70).unwrap_or(0) & 2 != 0, + prompt: own.s(3).unwrap_or_default(), + props: Default::default(), + multiline, + modes: attrib_modes(own.0), + } +} + fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> { // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { @@ -260,138 +384,16 @@ fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usi "LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }), "POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }), "CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }), - "ARC" => { - let mut a = - Arc { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs(), start: t.fd(50, 0.0).to_radians(), end: t.fd(51, 360.0).to_radians() }; - if c.extrusion.z < 0.0 { - // Mirrored OCS: angles run the other way. - let (s, e) = (std::f64::consts::PI - a.end, std::f64::consts::PI - a.start); - a.start = cadcraft_geom::norm_angle(s); - a.end = cadcraft_geom::norm_angle(e); - } - EntityKind::Arc(a) - } - "ELLIPSE" => { - let mut e = Ellipse { - center: t.p(10), - major: t.p(11), - ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), - start: t.fd(41, 0.0), - end: t.fd(42, std::f64::consts::TAU), - }; - if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { - // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) - // it points the other way, so parameter t lands where -t does in the XY plane. A full - // ellipse is symmetric and stays as it is. - let (s, e2) = (-e.end, -e.start); - e.start = cadcraft_geom::norm_angle(s); - e.end = cadcraft_geom::norm_angle(e2); - } - EntityKind::Ellipse(e) - } - "LWPOLYLINE" => { - let mut verts = Vec::new(); - let mut cur: Option = None; - for tg in tags { - match tg.code { - 10 => { - if let Some(v) = cur.take() { - verts.push(v); - } - cur = Some(PolyVertex { p: Vec2::new(tg.f64(), 0.0), ..Default::default() }); - } - 20 => { - if let Some(v) = cur.as_mut() { - v.p.y = tg.f64(); - } - } - 40 => { - if let Some(v) = cur.as_mut() { - v.start_width = tg.f64(); - } - } - 41 => { - if let Some(v) = cur.as_mut() { - v.end_width = tg.f64(); - } - } - 42 => { - if let Some(v) = cur.as_mut() { - v.bulge = tg.f64(); - } - } - _ => {} - } - } - verts.extend(cur); - let flags = t.i(70).unwrap_or(0); - if c.extrusion.z < 0.0 { - for v in &mut verts { - v.p.x = -v.p.x; - v.bulge = -v.bulge; - } - } - let pl = LwPolyline { - vertices: verts, - closed: flags & 1 != 0, - const_width: t.fd(43, 0.0), - elevation: t.fd(38, 0.0), - plinegen: flags & 128 != 0, - }; - // A region CADCraft wrote as polylines (`dxf_region`). - crate::dxf_region::from_polyline(tags, pl)? - } - "SPLINE" => { - let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize; - let knots = t.all_f(40); - let control = t.pts(10); - let weights = t.all_f(41); - let fit = t.pts(11); - let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy()); - let fit_opts = cadcraft_geom::FitOptions { - knots: Default::default(), - start_tangent: tangent(12), - end_tangent: tangent(13), - tolerance: t.fd(44, 0.0), - } - .sanitized(); - let closed = t.i(70).unwrap_or(0) & 1 != 0; - let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts }; - if !sp.is_valid() && fit.len() >= 2 { - sp = Spline::fit_with(&fit, sp.closed, fit_opts); - } else if !sp.is_valid() && sp.control.len() >= 2 { - sp = Spline::from_control(sp.control.clone(), degree); - } else if fit.len() >= 3 { - // DXF keeps the knots, not how they were spaced: recover it for later refits. - sp.fit_opts.knots = sp.infer_knot_param(); - } - EntityKind::Spline(sp) - } + "ARC" => EntityKind::Arc(arc(&c, &t)), + "ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)), + "LWPOLYLINE" => lwpolyline(tags, &c, &t)?, + "SPLINE" => EntityKind::Spline(spline(&t)), "HELIX" => EntityKind::Helix(helix(tags)), "RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }), "XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }), "TEXT" => EntityKind::Text(text_from(&t)), "MTEXT" => EntityKind::MText(mtext_from(tags, &c)), - "ATTDEF" => { - // A multiline definition has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(tags); - let multiline = multiline_attrib(embedded, &c); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - EntityKind::AttDef(Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: own.i(70).unwrap_or(0) & 2 != 0, - prompt: own.s(3).unwrap_or_default(), - props: Default::default(), - multiline, - modes: attrib_modes(own.0), - }) - } + "ATTDEF" => EntityKind::AttDef(attdef(tags, &c)), "INSERT" => { // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that @@ -1109,6 +1111,91 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m /// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that /// can't be placed are counted in `skipped`. +/// The entity of a POLYLINE, with its mesh faces (advancing `i` past its VERTEX records). `None` +/// for a mesh without faces. +fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) -> Option<(Common, EntityKind, Vec)> { + let flags = t.i(70).unwrap_or(0); + let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); + // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. + let mut faces: Vec<[i64; 4]> = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "VERTEX" { + let tt = T(t2); + let vflags = tt.i(70).unwrap_or(0); + if vflags & 128 != 0 && vflags & 64 == 0 { + faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); + } else if vflags & 16 == 0 { + pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); + } + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + let c = common(t); + let mut extra: Vec = Vec::new(); + let k = if flags & (16 | 64) != 0 { + // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the + // polyline's place. + let verts: Vec = pts.iter().map(|p| p.0).collect(); + let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); + let first = fs.next()?; + extra.extend(fs); + first + } else if flags & 8 != 0 { + EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) + } else { + // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. + let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; + EntityKind::LwPolyline(LwPolyline { + vertices: pts.iter().map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }).collect(), + closed: flags & 1 != 0, + const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), + elevation: t.p(10).z, + plinegen: flags & 128 != 0, + }) + }; + Some((c, k, extra)) +} + +/// The ATTRIB records after an INSERT, advancing `i` past them and their SEQEND. +fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_layer: &str) -> Vec { + let mut attribs = Vec::new(); + while let Some((k2, t2)) = recs.get(*i) { + if k2 == "ATTRIB" { + let tt = T(t2); + // A multiline attribute has its text as embedded MTEXT after its own groups. + let (own, embedded) = split_embedded(t2); + let multiline = multiline_attrib(embedded, &common(&tt)); + let own = T(own); + let mut text = text_from(&own); + if let Some(m) = &multiline { + text.value.clone_from(&m.read_as); + } + attribs.push(Attrib { + tag: own.s(2).unwrap_or_default(), + text, + invisible: own.i(70).unwrap_or(0) & 1 != 0, + constant: false, + prompt: String::new(), + props: attrib_props(&tt, ins_layer), + multiline, + modes: attrib_modes(own.0), + }); + *i += 1; + } else { + if k2 == "SEQEND" { + *i += 1; + } + break; + } + } + attribs +} + fn parse_entities( recs: &[(String, Vec)], d: &mut Drawing, @@ -1134,92 +1221,17 @@ fn parse_entities( i += 1; match kind.as_str() { "POLYLINE" => { - let flags = t.i(70).unwrap_or(0); - let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); - // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. - let mut faces: Vec<[i64; 4]> = Vec::new(); - while let Some((k2, t2)) = recs.get(i) { - if k2 == "VERTEX" { - let tt = T(t2); - let vflags = tt.i(70).unwrap_or(0); - if vflags & 128 != 0 && vflags & 64 == 0 { - faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); - } else if vflags & 16 == 0 { - pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); - } - i += 1; - } else { - if k2 == "SEQEND" { - i += 1; - } - break; - } - } - let c = common(&t); - let k = if flags & (16 | 64) != 0 { - // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the - // polyline's place. - let verts: Vec = pts.iter().map(|p| p.0).collect(); - let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, &t, flags) } - .into_iter() - .map(EntityKind::Face3d); - let Some(first) = fs.next() else { - *skipped = skipped.saturating_add(1); - continue; - }; - extra.extend(fs); - first - } else if flags & 8 != 0 { - EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) - } else { - // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. - let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; - EntityKind::LwPolyline(LwPolyline { - vertices: pts - .iter() - .map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }) - .collect(), - closed: flags & 1 != 0, - const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), - elevation: t.p(10).z, - plinegen: flags & 128 != 0, - }) + let Some((c, k, more)) = polyline_entity(recs, &mut i, &t) else { + *skipped = skipped.saturating_add(1); + continue; }; + extra = more; kindent = Some((c, k)); } "INSERT" => { if t.i(66) == Some(1) { - let mut attribs = Vec::new(); let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); - while let Some((k2, t2)) = recs.get(i) { - if k2 == "ATTRIB" { - let tt = T(t2); - // A multiline attribute has its text as embedded MTEXT after its own groups. - let (own, embedded) = split_embedded(t2); - let multiline = multiline_attrib(embedded, &common(&tt)); - let own = T(own); - let mut text = text_from(&own); - if let Some(m) = &multiline { - text.value.clone_from(&m.read_as); - } - attribs.push(Attrib { - tag: own.s(2).unwrap_or_default(), - text, - invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: false, - prompt: String::new(), - props: attrib_props(&tt, &ins_layer), - multiline, - modes: attrib_modes(own.0), - }); - i += 1; - } else { - if k2 == "SEQEND" { - i += 1; - } - break; - } - } + let attribs = insert_attribs(recs, &mut i, &ins_layer); if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { ins.attribs = attribs; } @@ -1688,6 +1700,13 @@ pub fn read(bytes: &[u8]) -> Result { read_budgeted(bytes, 4_000_000) } +/// A block definition read from BLOCKS: name, base point, description, entities (owner, paper +/// flag, entity) and block flags. +type BlockDef = (String, Vec3, String, Vec<(Option, bool, Entity)>, i64); + +/// A layout read from OBJECTS: name, tab order, paper block handle and page setup. +type LayoutDef = (String, u32, String, PageSetup); + /// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections. fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?; @@ -1700,8 +1719,8 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { // A file without $INSUNITS declares no units (#1209); only new drawings take the template's. d.header.set_i64("INSUNITS", 0); let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() }; - let mut blocks: Vec<(String, Vec3, String, Vec<(Option, bool, Entity)>, i64)> = Vec::new(); - let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new(); + let mut blocks: Vec = Vec::new(); + let mut layout_objs: Vec = Vec::new(); let mut entities = Vec::new(); let mut objs = Objects::default(); // Every handle another program's file already uses is taken before anything gets a new one: @@ -1721,73 +1740,109 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { match s.name.as_str() { "HEADER" => header(&s.tags, &mut d), "TABLES" => tables(&s.tags, &mut d, &mut rx), - "BLOCKS" => { - let recs = records(&s.tags); - for (k, tg) in &recs { - let t = T(tg); - if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { - rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); - } - } - let mut n = 0; - let mut i = 0; - while let Some((k, tg)) = recs.get(i) { - if k == "BLOCK" { - let t = T(tg); - let name = t.s(2).unwrap_or_default(); - let base = t.p(10); - let description = t.s(4).unwrap_or_default(); - // Block flags: 4 xref, 8 overlay. - let xref = t.i(70).unwrap_or(0) & 12; - let mut j = i + 1; - while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { - j += 1; - } - let body = recs.get(i + 1..j).unwrap_or(&[]); - let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left); - blocks.push((name, base, description, ents, xref)); - i = j + 1; - } else { - // A record outside any BLOCK ... ENDBLK pair. - if k != "ENDBLK" { - n = n.saturating_add(1); - } - i += 1; - } - } - count_skipped(&mut skipped, "BLOCKS", n); - } + "BLOCKS" => blocks.extend(read_blocks(&s.tags, &mut d, &mut rx, &mut table_cells_left, &mut skipped)), // A damaged file may split its entities over several ENTITIES sections (#865). "ENTITIES" => { let mut n = 0; entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left)); count_skipped(&mut skipped, "ENTITIES", n); } - "OBJECTS" => { - for (k, tg) in records(&s.tags) { - objs.add(&k, &tg); - if k == "LAYOUT" { - // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). - let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); - let tail = tg.get(start..).unwrap_or(&[]); - let t = T(tail); - // LAYOUT has two 330s: owner dict and the block record; take the last. - let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); - let plot = T(tg.get(..start).unwrap_or(&[])); - layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); - } - } - } + "OBJECTS" => read_objects(&s.tags, &mut objs, &mut layout_objs), _ => {} } } // A file CADCraft wrote names its own next handle (#495): its table records and objects // are numbered above it and rebuilt on save, so following $HANDSEED would drift every save. let own_seed = objs.xrecord_text(crate::dxf_ext::HANDSEED_KEY).and_then(|t| Handle::parse_hex(t.trim())); - // Layouts. + let (paper_block_for, first_layout) = setup_layouts(&mut d, &mut layout_objs, &rx); + place_blocks(&mut d, blocks, &mut rx, &paper_block_for, &first_layout); + place_entities(&mut d, entities, &rx, &paper_block_for, &first_layout); + apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); + objs.apply(&mut d, &rx); + crate::dxf_image::resolve(&secs, &mut d); + crate::dxf_mline::resolve(&secs, &mut d); + // Keep handles unique against the header's seed. + let seed = d.header.str("HANDSEED", ""); + if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) { + d.bump_handseed(Handle(h.0.saturating_sub(1))); + } + if d.layer("0").is_none() { + d.layers.insert(0, Layer::default()); + } + crate::dxf_raw::read(&secs, &mut d); + d.skipped_records = skipped; + Ok(d) +} + +/// Read the BLOCKS section: the xref paths of external blocks, then each block's entities. +fn read_blocks( + tags: &[Tag], + d: &mut Drawing, + rx: &mut Rx, + table_cells_left: &mut usize, + skipped: &mut std::collections::BTreeMap, +) -> Vec { + let recs = records(tags); + for (k, tg) in &recs { + let t = T(tg); + if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { + rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); + } + } + let mut blocks = Vec::new(); + let mut n = 0; + let mut i = 0; + while let Some((k, tg)) = recs.get(i) { + if k == "BLOCK" { + let t = T(tg); + let name = t.s(2).unwrap_or_default(); + let base = t.p(10); + let description = t.s(4).unwrap_or_default(); + // Block flags: 4 xref, 8 overlay. + let xref = t.i(70).unwrap_or(0) & 12; + let mut j = i + 1; + while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { + j += 1; + } + let body = recs.get(i + 1..j).unwrap_or(&[]); + let ents = parse_entities(body, d, rx, &mut n, table_cells_left); + blocks.push((name, base, description, ents, xref)); + i = j + 1; + } else { + // A record outside any BLOCK ... ENDBLK pair. + if k != "ENDBLK" { + n = n.saturating_add(1); + } + i += 1; + } + } + count_skipped(skipped, "BLOCKS", n); + blocks +} + +/// Read the OBJECTS section into `objs`, and each LAYOUT into `layout_objs`. +fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut Vec) { + for (k, tg) in records(tags) { + objs.add(&k, &tg); + if k == "LAYOUT" { + // Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1). + let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + let tail = tg.get(start..).unwrap_or(&[]); + let t = T(tail); + // LAYOUT has two 330s: owner dict and the block record; take the last. + let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); + let plot = T(tg.get(..start).unwrap_or(&[])); + layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); + } + } +} + +/// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the +/// layout that takes paper-space entities without an owner. +fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { let mut paper_block_for: HashMap = HashMap::new(); layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in &layout_objs { + for (name, order, br, page) in layout_objs.iter() { if name.eq_ignore_ascii_case("Model") { continue; } @@ -1806,7 +1861,12 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let first_tab = d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone()); d.active_layout = active.clone().filter(|a| Some(a) != first_tab.as_ref()); let first_layout = active.or_else(|| d.layouts.first().map(|l| l.name.clone())).unwrap_or_else(|| "Layout1".into()); - // Blocks: model/paper space blocks hold entities in newer files. + (paper_block_for, first_layout) +} + +/// Place the BLOCKS entities: model and paper space blocks go to their space, the rest become +/// block definitions. +fn place_blocks(d: &mut Drawing, blocks: Vec, rx: &mut Rx, paper_block_for: &HashMap, first_layout: &str) { for (name, base, description, ents, xref) in blocks { let up = name.to_ascii_uppercase(); if up == "*MODEL_SPACE" { @@ -1817,7 +1877,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { continue; } if up.starts_with("*PAPER_SPACE") { - let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.clone()); + let lname = paper_block_for.get(&up).cloned().unwrap_or_else(|| first_layout.to_string()); if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { for (_, _, e) in ents { l.entities.push(e); @@ -1857,13 +1917,22 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { } d.blocks.insert(name, std::sync::Arc::new(b)); } - // ENTITIES section: model space, or paper space when flagged (67=1). +} + +/// Place the ENTITIES section: model space, or paper space when flagged (67=1). +fn place_entities( + d: &mut Drawing, + entities: Vec<(Option, bool, Entity)>, + rx: &Rx, + paper_block_for: &HashMap, + first_layout: &str, +) { for (owner, paper, e) in entities { d.ensure_layer(&e.common.layer); let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase()); let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE")); if to_paper { - let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.clone()); + let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.to_string()); if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { l.entities.push(e); } @@ -1871,21 +1940,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { d.model.push(e); } } - apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); - objs.apply(&mut d, &rx); - crate::dxf_image::resolve(&secs, &mut d); - crate::dxf_mline::resolve(&secs, &mut d); - // Keep handles unique against the header's seed. - let seed = d.header.str("HANDSEED", ""); - if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) { - d.bump_handseed(Handle(h.0.saturating_sub(1))); - } - if d.layer("0").is_none() { - d.layers.insert(0, Layer::default()); - } - crate::dxf_raw::read(&secs, &mut d); - d.skipped_records = skipped; - Ok(d) } fn count_skipped(skipped: &mut std::collections::BTreeMap, section: &str, n: usize) { @@ -2054,13 +2108,7 @@ impl Objects { Some(text) } - fn apply(self, d: &mut Drawing, rx: &Rx) { - if let Some(v) = self.wipeout_frame { - d.header.set_i64("WIPEOUTFRAME", v); - } - if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { - d.header.set_str("CMLEADERSTYLE", &style); - } + fn apply_table_styles(&self, d: &mut Drawing) { // Table styles (named by their ACAD_TABLESTYLE dictionary entries). for (h, tags) in &self.table_styles { let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; @@ -2070,6 +2118,9 @@ impl Objects { None => d.table_styles.push(st), } } + } + + fn apply_table_style_fixes(&self, d: &mut Drawing, rx: &Rx) { for (h, sh) in &rx.table_style_fix { let name = self.names.get(sh).cloned(); // A table from another writer has no scale of its own: its text height is its @@ -2090,6 +2141,61 @@ impl Objects { } }); } + } + + fn apply_mleader_styles(&self, d: &mut Drawing, rx: &Rx) { + // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). + for (h, tags) in &self.mleader_styles { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let def = MLeaderStyle::default(); + let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); + let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); + let st = MLeaderStyle { + name: name.clone(), + arrow_size: num(44, def.arrow_size, 0.0), + text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), + landing_gap: num(42, def.landing_gap, 0.0), + dogleg: num(43, def.dogleg, 0.0), + text_style: text_style.unwrap_or(def.text_style), + annotative: t.i(296).unwrap_or(0) != 0, + }; + match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + None => d.mleader_styles.push(st), + } + } + } + + fn apply_groups(&self, d: &mut Drawing) { + // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + for (h, tags) in &self.groups { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let members: Vec = tags + .iter() + .filter(|x| x.code == 340) + .filter_map(|x| Handle::parse_hex(&x.str())) + .filter(|m| d.entity(*m).is_some()) + .take(MAX_OBJECTS) + .collect(); + let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; + match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = g, + None => d.groups.push(g), + } + } + } + + fn apply(self, d: &mut Drawing, rx: &Rx) { + if let Some(v) = self.wipeout_frame { + d.header.set_i64("WIPEOUTFRAME", v); + } + if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { + d.header.set_str("CMLEADERSTYLE", &style); + } + self.apply_table_styles(d); + self.apply_table_style_fixes(d, rx); // Parametric constraints and parameters. if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) { d.constraints = c; @@ -2113,50 +2219,14 @@ impl Objects { if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) { d.plot_style_tables = tables; } - // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). - for (h, tags) in &self.mleader_styles { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let def = MLeaderStyle::default(); - let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); - let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); - let st = MLeaderStyle { - name: name.clone(), - arrow_size: num(44, def.arrow_size, 0.0), - text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), - landing_gap: num(42, def.landing_gap, 0.0), - dogleg: num(43, def.dogleg, 0.0), - text_style: text_style.unwrap_or(def.text_style), - annotative: t.i(296).unwrap_or(0) != 0, - }; - match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = st, - None => d.mleader_styles.push(st), - } - } + self.apply_mleader_styles(d, rx); crate::dxf_mleader::apply(d, &rx.mleader_fix, &self.names); for (name, view) in &self.layout_views { if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) { l.view = Some(*view); } } - // Groups (named by their ACAD_GROUP dictionary entries); members that exist. - for (h, tags) in &self.groups { - let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; - let t = T(tags); - let members: Vec = tags - .iter() - .filter(|x| x.code == 340) - .filter_map(|x| Handle::parse_hex(&x.str())) - .filter(|m| d.entity(*m).is_some()) - .take(MAX_OBJECTS) - .collect(); - let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; - match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { - Some(x) => *x = g, - None => d.groups.push(g), - } - } + self.apply_groups(d); // Standard associativity, for dimensions without CADCraft's exact links. for tags in &self.dimassocs { dimassoc(d, tags); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index a4d9f5ea7..3f35233a8 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -17,7 +17,7 @@ use std::fmt::Write as _; use cadcraft_color::Rgb; use cadcraft_doc::{Drawing, Handle, PageSetup, Space}; use cadcraft_geom::{Bounds2, Vec2}; -use cadcraft_render::{Kind, Sheet, clip, paper}; +use cadcraft_render::{DPrim, Kind, Sheet, clip, paper}; use serde_json::Value; use craft_pdf::write::backend as kr; @@ -808,7 +808,7 @@ fn krilla_failure(e: KrillaError) -> Failure { /// as text instead: the glyph at font size 1 under its glyph matrix (the matrix axes are one /// em). fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts::Fonts) -> std::result::Result<(), Failure> { - let (list, map) = (&p.list, &p.map); + let list = &p.list; let texts = fonts::texts(list); let white = Rgb(255, 255, 255); let mut batch: Option<(StrokeStyle, PathBuilder)> = None; @@ -829,100 +829,126 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts: let t = if prim.kind == Kind::Mask { 0 } else { prim.transparency.min(cadcraft_render::MAX_TRANSPARENCY) }; let w = f64::from(prim.lw) * PT_PER_MM; let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 }; + let m = Mark { rgb, t, w }; match prim.kind { - Kind::Polyline | Kind::Infinite { .. } => { - let pts: Vec = match prim.kind { - Kind::Infinite { ray } => match (raw.first(), raw.get(1)) { - (Some(base), Some(dir)) => { - let a = map.pt(*base); - let dir_pt = map.pt(*base + *dir) - a; - match clip::clip_infinite(a, dir_pt, ray, &p.clip_pt) { - Some((x, y)) => vec![x, y], - None => continue, - } - } - _ => continue, - }, - _ => raw.iter().map(|q| map.pt(*q)).collect(), - }; - if pts.len() < 2 { - continue; - } - // With a vertex out of range, keep the pieces inside the plot area, widened so - // a wide stroke just outside it still shows its edge. - let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(w + 1.0)) }; - let style = StrokeStyle { rgb, width: w as f32, transparency: t }; - if t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) { - flush(s, &mut batch); - } - for run in runs { - let Some(pts) = run.iter().map(|q| coord(*q)).collect::>>() else { continue }; - // The writer leaves out a path without extent, so a zero-length line is - // painted as the round-cap dot its stroke would make. - if let Some(&(x, y)) = pts.first() - && pts.iter().all(|q| *q == (x, y)) - { - flush(s, &mut batch); - let d = if w > 0.0 { w as f32 } else { HAIRLINE_DOT_PT }; - if let Some(path) = circle_path(x, y, d / 2.0) { - fill_path(s, &path, rgb, t); - } - continue; - } - let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new())); - for (j, (x, y)) in pts.iter().enumerate() { - if j == 0 { - pb.move_to(*x, *y); - } else { - pb.line_to(*x, *y); - } - } - } - } + Kind::Polyline | Kind::Infinite { .. } => polyline(s, &mut batch, p, prim, raw, m), Kind::Tris | Kind::Mask => { flush(s, &mut batch); // A wipeout's mask is paper white. - let rgb = if prim.kind == Kind::Mask { white } else { rgb }; - if let Some(text) = u32::try_from(i).ok().and_then(|i| texts.get(&i)) - && let Some(font) = fonts.font(&text.glyph.face) - { - glyph(s, font, text, map, rgb, t); - continue; - } - let mapped: Vec = raw.iter().map(|q| map.pt(*q)).collect(); - // With a vertex out of range, keep the parts inside the plot area. - let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) }; - let mut pb = PathBuilder::new(); - for tri in tris.as_chunks::<3>().0 { - let Some(v) = tri.iter().map(|q| coord(*q)).collect::>>() else { continue }; - for (j, (x, y)) in v.iter().enumerate() { - if j == 0 { - pb.move_to(*x, *y); - } else { - pb.line_to(*x, *y); - } - } - pb.close(); - } - if let Some(path) = pb.finish() { - fill_path(s, &path, rgb, t); - } - } - Kind::Point => { - let Some(q) = raw.first() else { continue }; - flush(s, &mut batch); - let half = (w.max(0.5)) / 2.0; - let c = map.pt(*q); - if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) { - fill_path(s, &path, rgb, t); + let m = if prim.kind == Kind::Mask { Mark { rgb: white, ..m } } else { m }; + let text_font = + u32::try_from(i).ok().and_then(|i| texts.get(&i).copied()).and_then(|text| fonts.font(&text.glyph.face).map(|font| (text, font))); + match text_font { + Some((text, font)) => glyph(s, font, text, &p.map, m.rgb, m.t), + None => fill_tris(s, p, raw, m.rgb, m.t), } } + Kind::Point => point(s, &mut batch, &p.map, raw, m), } } flush(s, &mut batch); layer_changed(s, layer, None) } +/// How a primitive is painted: colour, transparency (percent) and lineweight (points). +#[derive(Clone, Copy)] +struct Mark { + rgb: Rgb, + t: u8, + w: f64, +} + +/// Stroke a polyline or infinite line, clipped to the plot area, into the open batch. +fn polyline(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, p: &SheetPage, prim: &DPrim, raw: &[Vec2], m: Mark) { + let Some(pts) = polyline_points(prim, raw, &p.map, &p.clip_pt) else { return }; + if pts.len() < 2 { + return; + } + // With a vertex out of range, keep the pieces inside the plot area, widened so + // a wide stroke just outside it still shows its edge. + let runs = if pts.iter().all(|q| coord(*q).is_some()) { vec![pts] } else { clip::clip_polyline(&pts, &p.clip_pt.expand(m.w + 1.0)) }; + let style = StrokeStyle { rgb: m.rgb, width: m.w as f32, transparency: m.t }; + if m.t > 0 || batch.as_ref().is_some_and(|(b, _)| *b != style) { + flush(s, batch); + } + for run in runs { + stroke_run(s, batch, &run, style, m); + } +} + +/// The page points of a polyline, or of an infinite line clipped to the plot area; `None` when +/// there is nothing to draw. +fn polyline_points(prim: &DPrim, raw: &[Vec2], map: &Map, clip_pt: &Bounds2) -> Option> { + match prim.kind { + Kind::Infinite { ray } => { + let (Some(base), Some(dir)) = (raw.first(), raw.get(1)) else { return None }; + let a = map.pt(*base); + let dir_pt = map.pt(*base + *dir) - a; + let (x, y) = clip::clip_infinite(a, dir_pt, ray, clip_pt)?; + Some(vec![x, y]) + } + _ => Some(raw.iter().map(|q| map.pt(*q)).collect()), + } +} + +/// Add one run of a polyline to the batch, or paint it as a dot when it has no extent. +fn stroke_run(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, run: &[Vec2], style: StrokeStyle, m: Mark) { + let Some(pts) = run.iter().map(|q| coord(*q)).collect::>>() else { return }; + // The writer leaves out a path without extent, so a zero-length line is + // painted as the round-cap dot its stroke would make. + if let Some(&(x, y)) = pts.first() + && pts.iter().all(|q| *q == (x, y)) + { + flush(s, batch); + let d = if m.w > 0.0 { m.w as f32 } else { HAIRLINE_DOT_PT }; + if let Some(path) = circle_path(x, y, d / 2.0) { + fill_path(s, &path, m.rgb, m.t); + } + return; + } + let (_, pb) = batch.get_or_insert_with(|| (style, PathBuilder::new())); + for (j, (x, y)) in pts.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } + } +} + +/// Fill a set of triangles, clipped to the plot area. +fn fill_tris(s: &mut Surface<'_>, p: &SheetPage, raw: &[Vec2], rgb: Rgb, t: u8) { + let mapped: Vec = raw.iter().map(|q| p.map.pt(*q)).collect(); + // With a vertex out of range, keep the parts inside the plot area. + let tris = if mapped.iter().all(|q| coord(*q).is_some()) { mapped } else { clip::clip_triangles(&mapped, &p.clip_pt) }; + let mut pb = PathBuilder::new(); + for tri in tris.as_chunks::<3>().0 { + let Some(v) = tri.iter().map(|q| coord(*q)).collect::>>() else { continue }; + for (j, (x, y)) in v.iter().enumerate() { + if j == 0 { + pb.move_to(*x, *y); + } else { + pb.line_to(*x, *y); + } + } + pb.close(); + } + if let Some(path) = pb.finish() { + fill_path(s, &path, rgb, t); + } +} + +/// Paint a point marker: a small square at the point. +fn point(s: &mut Surface<'_>, batch: &mut Option<(StrokeStyle, PathBuilder)>, map: &Map, raw: &[Vec2], m: Mark) { + let Some(q) = raw.first() else { return }; + flush(s, batch); + let half = (m.w.max(0.5)) / 2.0; + let c = map.pt(*q); + if let Some(path) = rect_path(c - Vec2::new(half, half), c + Vec2::new(half, half)) { + fill_path(s, &path, m.rgb, m.t); + } +} + /// Write one glyph as text in its embedded font: font size 1, placed by the glyph's matrix (one /// em along each axis). A glyph whose matrix is out of range or collapses is left out. fn glyph(s: &mut Surface<'_>, font: kr::text::Font, t: &cadcraft_render::PlotText, map: &Map, rgb: Rgb, transparency: u8) { From 53080b174883cded09bd5ce87d496eaa00cf9e03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:43:08 -0400 Subject: [PATCH 87/91] refactor: split complex matchers, scanners and solver setup into helpers (#2186) Pure extraction, no behaviour change: same order of side effects, same budget charges, same early returns and error values. Clears cognitive-complexity findings on glob_inner, the PDF test ops scanner, snap_ends, the loops inquiry run and build_budgeted (S3776). --- crates/constraints/src/lib.rs | 91 +++++++++------- crates/engine/src/cmd/inquiry/loops.rs | 144 ++++++++++++++----------- crates/engine/src/lisp/builtins.rs | 79 +++++++------- crates/geom/src/region.rs | 72 +++++++------ crates/io/tests/support/pdf_read.rs | 73 +++++++------ 5 files changed, 257 insertions(+), 202 deletions(-) diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 72a05b6a2..89c6276cd 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -358,6 +358,56 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec> { groups.into_values().collect() } +/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones +/// less, angles and ratios by size², and contact parameters move freely. +fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec { + let mut weights = vec![1.0; model.x0.len()]; + for s in &model.slots { + let w = if opts.keep.contains(&s.handle) { + 1e4 + } else if opts.prefer_move.contains(&s.handle) { + 1e-2 + } else { + 1.0 + }; + for j in s.off..s.off + s.len { + if let Some(slot) = weights.get_mut(j) { + *slot = w; + } + } + for (j, f) in slot_scales(model, s) { + if let Some(slot) = weights.get_mut(j) { + *slot *= f; + } + } + } + // Contact parameters move freely. + let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0); + for w in weights.iter_mut().skip(geo) { + *w = 1e-3; + } + weights +} + +/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of +/// points is what is minimised. +fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> { + let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6)); + match s.shape { + model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))], + model::Shape::Ellipse { arc } => { + let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6); + let mut v = vec![(s.off + 4, a2)]; + if arc { + v.extend([(s.off + 5, a2), (s.off + 6, a2)]); + } + v + } + model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))], + _ => Vec::new(), + } +} + /// Build the component systems. Constraints that cannot be interpreted are returned as skipped. pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec, Vec<(u32, String)>), SolveError> { let mut members_left = model::MAX_RIGID_WORK; @@ -396,46 +446,7 @@ pub(crate) fn build_budgeted( if built.is_empty() { continue; } - let mut weights = vec![1.0; model.x0.len()]; - for s in &model.slots { - let w = if opts.keep.contains(&s.handle) { - 1e4 - } else if opts.prefer_move.contains(&s.handle) { - 1e-2 - } else { - 1.0 - }; - for j in s.off..s.off + s.len { - if let Some(slot) = weights.get_mut(j) { - *slot = w; - } - } - // Angles and ratios: weight by size² so the movement of points is what is minimised. - let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6)); - let scaled: Vec<(usize, f64)> = match s.shape { - model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))], - model::Shape::Ellipse { arc } => { - let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6); - let mut v = vec![(s.off + 4, a2)]; - if arc { - v.extend([(s.off + 5, a2), (s.off + 6, a2)]); - } - v - } - model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))], - _ => Vec::new(), - }; - for (j, f) in scaled { - if let Some(slot) = weights.get_mut(j) { - *slot *= f; - } - } - } - // Contact parameters move freely. - let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0); - for w in weights.iter_mut().skip(geo) { - *w = 1e-3; - } + let weights = solver_weights(&model, opts); let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect(); let tol = scale_tol(&model.x0); comps.push(Comp { model, built, weights, held, tol }); diff --git a/crates/engine/src/cmd/inquiry/loops.rs b/crates/engine/src/cmd/inquiry/loops.rs index 20c4ac6b8..0be936451 100644 --- a/crates/engine/src/cmd/inquiry/loops.rs +++ b/crates/engine/src/cmd/inquiry/loops.rs @@ -117,21 +117,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { // Endpoints closer than the tolerance meet. Transitive components of three or more ends are // branch junctions; a component of exactly two is a simple joint. - let mut parent: Vec = (0..n).collect(); - for i in 0..n { - let Some(a) = ends.get(i) else { continue }; - for j in (i + 1)..n { - let Some(b) = ends.get(j) else { continue }; - if a.at.dist(b.at) <= tol { - let (ra, rb) = (find(&mut parent, i), find(&mut parent, j)); - if ra != rb - && let Some(slot) = parent.get_mut(ra) - { - *slot = rb; - } - } - } - } + let mut parent = merge_ends(&ends, tol); let mut groups: BTreeMap> = BTreeMap::new(); for i in 0..n { let root = find(&mut parent, i); @@ -140,54 +126,8 @@ pub fn run(s: &mut Session, p: &Value) -> Result { // Junctions are reported and their pieces left out of the chains: not resolved. let mut excluded = vec![false; pieces.len()]; - let mut junctions: Vec = Vec::new(); - let mut joints: Vec<(usize, usize)> = Vec::new(); - for group in groups.values() { - match group.as_slice() { - &[a, b] => joints.push((a, b)), - [_, _, ..] => { - let scale = group.len() as f64; - let mut at = Vec2::ZERO; - let mut members: Vec = Vec::new(); - for &e in group { - let Some(end) = ends.get(e) else { continue }; - at += end.at / scale; - let Some(piece) = pieces.get(end.piece) else { continue }; - if !members.contains(&piece.handle) { - members.push(piece.handle); - } - if let Some(slot) = excluded.get_mut(end.piece) { - *slot = true; - } - } - junctions.push(json!({ - "at": [at.x, at.y], - "ends": group.len(), - "members": members.iter().map(|h| h.hex()).collect::>(), - })); - } - _ => {} - } - } - - // A joint links the two ends for the walk. A joint into an excluded piece is not a link, and - // a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop. - let mut link: Vec> = vec![None; n]; - for (a, b) in joints { - let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue }; - if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) { - continue; - } - if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) { - continue; - } - if let Some(slot) = link.get_mut(a) { - *slot = Some(b); - } - if let Some(slot) = link.get_mut(b) { - *slot = Some(a); - } - } + let (joints, junctions) = sort_groups(&groups, &ends, &pieces, &mut excluded); + let link = joint_links(joints, &ends, &pieces, &excluded); let mut visited = vec![false; pieces.len()]; let mut loops: Vec = Vec::new(); @@ -225,6 +165,84 @@ pub fn run(s: &mut Session, p: &Value) -> Result { } /// A report with nothing analyzed: an empty drawing, or one past the pair cap. +/// Union-find over the ends: ends closer than `tol` share a root. Returns each end's parent. +fn merge_ends(ends: &[End], tol: f64) -> Vec { + let n = ends.len(); + let mut parent: Vec = (0..n).collect(); + for i in 0..n { + let Some(a) = ends.get(i) else { continue }; + for j in (i + 1)..n { + let Some(b) = ends.get(j) else { continue }; + if a.at.dist(b.at) <= tol { + let (ra, rb) = (find(&mut parent, i), find(&mut parent, j)); + if ra != rb + && let Some(slot) = parent.get_mut(ra) + { + *slot = rb; + } + } + } + } + parent +} + +/// Each group of two ends is a joint (end indices); each of three or more is a junction, reported +/// with its pieces marked `excluded`. +fn sort_groups(groups: &BTreeMap>, ends: &[End], pieces: &[Piece], excluded: &mut [bool]) -> (Vec<(usize, usize)>, Vec) { + let mut joints: Vec<(usize, usize)> = Vec::new(); + let mut junctions: Vec = Vec::new(); + for group in groups.values() { + match group.as_slice() { + &[a, b] => joints.push((a, b)), + [_, _, ..] => { + let scale = group.len() as f64; + let mut at = Vec2::ZERO; + let mut members: Vec = Vec::new(); + for &e in group { + let Some(end) = ends.get(e) else { continue }; + at += end.at / scale; + let Some(piece) = pieces.get(end.piece) else { continue }; + if !members.contains(&piece.handle) { + members.push(piece.handle); + } + if let Some(slot) = excluded.get_mut(end.piece) { + *slot = true; + } + } + junctions.push(json!({ + "at": [at.x, at.y], + "ends": group.len(), + "members": members.iter().map(|h| h.hex()).collect::>(), + })); + } + _ => {} + } + } + (joints, junctions) +} + +/// The link each end takes from the joints. A joint into an excluded piece is not a link, and +/// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop. +fn joint_links(joints: Vec<(usize, usize)>, ends: &[End], pieces: &[Piece], excluded: &[bool]) -> Vec> { + let mut link: Vec> = vec![None; ends.len()]; + for (a, b) in joints { + let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue }; + if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) { + continue; + } + if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) { + continue; + } + if let Some(slot) = link.get_mut(a) { + *slot = Some(b); + } + if let Some(slot) = link.get_mut(b) { + *slot = Some(a); + } + } + link +} + fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value { json!({ "schema": 1, diff --git a/crates/engine/src/lisp/builtins.rs b/crates/engine/src/lisp/builtins.rs index 8e89d667c..b3829a6a4 100644 --- a/crates/engine/src/lisp/builtins.rs +++ b/crates/engine/src/lisp/builtins.rs @@ -785,6 +785,48 @@ fn glob_cached( Ok(matched) } +/// For the bracket class opening at `pat[p]`, whether `text[t]` is in it: the index of its closing +/// `]` when it is, `None` when it is not or the class is never closed. +fn class_end(text: &[char], pat: &[char], p: usize, t: usize, budget: &mut u64) -> Result, ()> { + let mut close = p; + while let Some(c) = pat.get(close) { + charge_match(budget)?; + if *c == ']' { + break; + } + close += 1; + } + if close == pat.len() { + return Ok(None); + } + let Some(&c) = text.get(t) else { return Ok(None) }; + let set = &pat[p + 1..close]; + let (neg, set) = match set.split_first() { + Some(('~', r)) if !r.is_empty() => (true, r), + _ => (false, set), + }; + let mut hit = false; + let mut i = 0; + while i < set.len() { + charge_match(budget)?; + if set.get(i + 1) == Some(&'-') && i + 2 < set.len() { + if set[i] <= c && c <= set[i + 2] { + hit = true; + } + i += 3; + } else { + if set[i] == c { + hit = true; + } + i += 1; + } + } + if hit == neg { + return Ok(None); + } + Ok(Some(close)) +} + /// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits. fn glob_inner( text: &[char], @@ -822,42 +864,7 @@ fn glob_inner( p += 2; } '[' => { - let mut close = p; - while let Some(c) = pat.get(close) { - charge_match(budget)?; - if *c == ']' { - break; - } - close += 1; - } - if close == pat.len() { - return Ok(false); - } - let Some(&c) = text.get(t) else { return Ok(false) }; - let set = &pat[p + 1..close]; - let (neg, set) = match set.split_first() { - Some(('~', r)) if !r.is_empty() => (true, r), - _ => (false, set), - }; - let mut hit = false; - let mut i = 0; - while i < set.len() { - charge_match(budget)?; - if set.get(i + 1) == Some(&'-') && i + 2 < set.len() { - if set[i] <= c && c <= set[i + 2] { - hit = true; - } - i += 3; - } else { - if set[i] == c { - hit = true; - } - i += 1; - } - } - if hit == neg { - return Ok(false); - } + let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) }; t += 1; p = close + 1; } diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index bef60b530..c186534c9 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -447,6 +447,44 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec { out } +/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve, +/// segment, parameter, point). `None` when the end touches a curve, or none is near. `Err` when +/// the budget of segment checks runs out. +fn nearest_curve(polys: &[Vec], i: usize, e: Vec2, snap: f64, tol: f64, budget: &mut usize) -> Result, ()> { + let mut best: Option<(f64, usize, usize, f64, Vec2)> = None; + let mut touching = false; + for (j, other) in polys.iter().enumerate() { + if j == i { + continue; + } + for (k, w) in other.windows(2).enumerate() { + if *budget == 0 { + return Err(()); + } + *budget -= 1; + let [a, b] = w else { continue }; + if !(a.is_finite() && b.is_finite()) + || e.x < a.x.min(b.x) - snap + || e.x > a.x.max(b.x) + snap + || e.y < a.y.min(b.y) - snap + || e.y > a.y.max(b.y) + snap + { + continue; + } + let line = Line::new(*a, *b); + let t = line.param_of(e).clamp(0.0, 1.0); + let q = line.at(t); + let d = q.dist(e); + if d <= tol { + touching = true; + } else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) { + best = Some((d, j, k, t, q)); + } + } + } + Ok(best.filter(|_| !touching).map(|(_, j, k, t, q)| (j, k, t, q))) +} + /// The curves with each open end that stops within `snap` of another curve moved onto that /// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened /// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve, @@ -474,38 +512,8 @@ pub fn snap_ends(polys: &[Vec], snap: f64, tol: f64) -> Vec> { if !e.is_finite() { continue; } - let mut best: Option<(f64, usize, usize, f64, Vec2)> = None; - let mut touching = false; - for (j, other) in polys.iter().enumerate() { - if j == i { - continue; - } - for (k, w) in other.windows(2).enumerate() { - if budget == 0 { - break 'ends; - } - budget -= 1; - let [a, b] = w else { continue }; - if !(a.is_finite() && b.is_finite()) - || e.x < a.x.min(b.x) - snap - || e.x > a.x.max(b.x) + snap - || e.y < a.y.min(b.y) - snap - || e.y > a.y.max(b.y) + snap - { - continue; - } - let line = Line::new(*a, *b); - let t = line.param_of(e).clamp(0.0, 1.0); - let q = line.at(t); - let d = q.dist(e); - if d <= tol { - touching = true; - } else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) { - best = Some((d, j, k, t, q)); - } - } - } - let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue }; + let Ok(near) = nearest_curve(polys, i, e, snap, tol, &mut budget) else { break 'ends }; + let Some((j, k, t, q)) = near else { continue }; let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue }; *slot = q; let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1))); diff --git a/crates/io/tests/support/pdf_read.rs b/crates/io/tests/support/pdf_read.rs index 992544089..0553a46e4 100644 --- a/crates/io/tests/support/pdf_read.rs +++ b/crates/io/tests/support/pdf_read.rs @@ -235,6 +235,46 @@ pub struct Op { pub names: Vec, } +/// The index just past the string literal opening at `b[i]`, honouring escapes and nesting. +fn skip_string(b: &[char], mut i: usize) -> usize { + let mut depth = 0; + while i < b.len() { + match b[i] { + '\\' => i += 1, + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + i += 1; + } + i + 1 +} + +/// The index just past the inline dictionary opening at `b[i]` (`<<`). +fn skip_dict(b: &[char], mut i: usize) -> usize { + let mut depth = 0; + while i < b.len() { + if b[i] == '<' && b.get(i + 1) == Some(&'<') { + depth += 1; + i += 1; + } else if b[i] == '>' && b.get(i + 1) == Some(&'>') { + depth -= 1; + i += 1; + if depth == 0 { + i += 1; + break; + } + } + i += 1; + } + i +} + /// The operators of content text in order, each with its numeric operands (other operands /// are skipped). pub fn ops(content: &str) -> Vec { @@ -253,39 +293,10 @@ pub fn ops(content: &str) -> Vec { i += 1; } } else if c == '(' { - let mut depth = 0; - while i < b.len() { - match b[i] { - '\\' => i += 1, - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - break; - } - } - _ => {} - } - i += 1; - } - i += 1; + i = skip_string(&b, i); } else if c == '<' && b.get(i + 1) == Some(&'<') { // An inline dictionary (marked-content properties): skip to its end. - let mut depth = 0; - while i < b.len() { - if b[i] == '<' && b.get(i + 1) == Some(&'<') { - depth += 1; - i += 1; - } else if b[i] == '>' && b.get(i + 1) == Some(&'>') { - depth -= 1; - i += 1; - if depth == 0 { - i += 1; - break; - } - } - i += 1; - } + i = skip_dict(&b, i); } else if c == '<' { while i < b.len() && b[i] != '>' { i += 1; From 7d33d9d4dd6ad0c339c0cdf2d66a127da7fc6485 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:50:19 -0400 Subject: [PATCH 88/91] refactor: split complex input, explode, sweep, cache and options code into helpers (#2187) Pure extraction, no behaviour change: same order of side effects, same early returns and error values. Clears cognitive-complexity findings on AreaM::input, explode_kind, hatch sweep, the COMPARE overlay paint and the Files options tab (S3776). --- crates/engine/src/cmd/inquiry.rs | 97 +++++----- crates/engine/src/cmd/modify.rs | 257 +++++++++++++-------------- crates/render/src/hatch.rs | 64 ++++--- crates/ui-egui/src/canvas/compare.rs | 32 ++-- crates/ui-egui/src/options/dialog.rs | 138 +++++++------- 5 files changed, 313 insertions(+), 275 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index f1bfd8073..4d82d5c5c 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -963,6 +963,57 @@ impl AreaM { AreaMode::Subtract => self.total - v, } } + /// Input while a height is pending: typed, or given by two points (their distance). + fn height_input(&mut self, s: &mut Session, i: Input) -> Step { + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return self.height(s, p.dist(from)); + } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return self.height(s, h), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), + _ => {} + } + Step::Continue + } + + /// Input while the objects to measure are picked. + fn object_input(&mut self, s: &mut Session, i: Input) -> Step { + match i { + Input::Pick(hs) => { + for h in hs { + match object_area(s, h) { + Ok((a, per)) => { + if self.measured(s, a, per) == Step::Done { + return Step::Done; + } + // A single volume takes the first object with an area. + if self.volume && self.mode == AreaMode::Single { + break; + } + } + Err(_) => s.echo("The selected object has no area."), + } + } + } + Input::Enter => { + self.object = false; + if self.mode == AreaMode::Single { + return Step::Done; + } + } + Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), + _ => {} + } + Step::Continue + } + /// Measure the oldest pending area's volume with height `h`. fn height(&mut self, s: &mut Session, h: f64) -> Step { match self.pending.first().map(|a| a * h.abs()) { @@ -1042,52 +1093,10 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - // A height is typed, or given by two points (their distance). - match (i, self.height_from) { - (Input::Point(p), None) => self.height_from = Some(p), - (Input::Point(p), Some(from)) => { - self.height_from = None; - return Ok(self.height(s, p.dist(from))); - } - (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => return Ok(self.height(s, h)), - None => s.echo("Requires a distance."), - }, - (Input::Enter, None) => s.echo("Requires a height."), - (Input::Enter, Some(_)) => s.echo("Requires a second point."), - (Input::Text(_), Some(_)) => s.echo("Requires a point."), - _ => {} - } - return Ok(Step::Continue); + return Ok(self.height_input(s, i)); } if self.object { - match i { - Input::Pick(hs) => { - for h in hs { - match object_area(s, h) { - Ok((a, per)) => { - if self.measured(s, a, per) == Step::Done { - return Ok(Step::Done); - } - // A single volume takes the first object with an area. - if self.volume && self.mode == AreaMode::Single { - break; - } - } - Err(_) => s.echo("The selected object has no area."), - } - } - } - Input::Enter => { - self.object = false; - if self.mode == AreaMode::Single { - return Ok(Step::Done); - } - } - Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), - _ => {} - } - return Ok(Step::Continue); + return Ok(self.object_input(s, i)); } if self.length { if let Some(p) = length_input(&self.pts, i)? { diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index c25eb50c8..f2116e9d0 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1296,6 +1296,128 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result { // ---------------- explode / array / order / break / join ---------------- +fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { + if let Some(items) = super::array::explode_array(d, e) { + return Some(items); + } + let blk = d.block(&ins.block)?; + let m = ins.transform(blk.base.xy()); + Some( + blk.entities + .iter() + .map(|be| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + if ne.common.layer == "0" { + ne.common.layer = e.common.layer.clone(); + } + if ne.common.color == cadcraft_color::Color::ByBlock { + ne.common.color = e.common.color; + } + ne + }) + .collect(), + ) +} + +fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec { + cadcraft_fonts::plain_mtext(&m.contents) + .split('\n') + .enumerate() + .map(|(i, l)| { + let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); + Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Text(cadcraft_doc::Text { + insert: v3(at), + align_pt: None, + height: m.height, + value: l.into(), + rotation: m.rotation, + width_factor: 1.0, + oblique: 0.0, + style: m.style.clone(), + halign: Default::default(), + valign: Default::default(), + }), + } + }) + .collect() +} + +/// The entities of a boundary's lines and arcs, on the object's layer and style. +fn segment_entities(e: &Entity, segs: impl IntoIterator) -> Vec { + segs.into_iter() + .map(|s| { + let k = match s { + Segment::Line(l) => line(l.a, l.b), + Segment::Arc { arc: a, .. } => arc(&a), + }; + Entity { handle: Handle(0), common: e.common.clone(), kind: k } + }) + .collect() +} + +fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec { + // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = cadcraft_render::dimension_in(d, dm); + let line_entity = |l: &Vec| { + if l.len() == 2 { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) + } else { + Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) + } + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + let text_marks = &g.text_marks; + let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); + let mut v: Vec = marks(false).filter_map(&line_entity).collect(); + for t in &g.fills { + if let [a, b, c] = t.as_slice() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), + }); + } + } + // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and + // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + let fill = style.text_fill_color; + let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; + v.push(Entity { + handle: Handle(0), + common: cadcraft_doc::Common { color, ..e.common.clone() }, + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), + }); + } + v.extend(marks(true).filter_map(&line_entity)); + if !g.value.is_empty() { + v.push(Entity { + handle: Handle(0), + common: e.common.clone(), + kind: EntityKind::MText(cadcraft_doc::MText { + insert: v3(g.text_pos), + height: g.text_height, + width: 0.0, + attach: 5, + rotation: g.text_angle, + style: style.text_style.clone(), + contents: g.mtext_contents(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }), + }); + } + v +} + pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { // An annotative object explodes into plain objects as it draws at the current annotation scale. if cadcraft_doc::is_annotative(e) { @@ -1309,42 +1431,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - Some( - pl.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ) - } - EntityKind::Insert(ins) => { - if let Some(items) = super::array::explode_array(d, e) { - return Some(items); - } - let blk = d.block(&ins.block)?; - let m = ins.transform(blk.base.xy()); - Some( - blk.entities - .iter() - .map(|be| { - let mut ne = (**be).clone(); - ne.kind.transform(&m); - if ne.common.layer == "0" { - ne.common.layer = e.common.layer.clone(); - } - if ne.common.color == cadcraft_color::Color::ByBlock { - ne.common.color = e.common.color; - } - ne - }) - .collect(), - ) + Some(segment_entities(e, pl.segments())) } + EntityKind::Insert(ins) => explode_block_ref(d, e, ins), // A helix explodes into its spline (seen from above). EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]), EntityKind::Polyline3d(p) => Some( @@ -1357,102 +1446,10 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Some(super::draw2::mline::explode(e, m)), - EntityKind::MText(m) => Some( - cadcraft_fonts::plain_mtext(&m.contents) - .split('\n') - .enumerate() - .map(|(i, l)| { - let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); - Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Text(cadcraft_doc::Text { - insert: v3(at), - align_pt: None, - height: m.height, - value: l.into(), - rotation: m.rotation, - width_factor: 1.0, - oblique: 0.0, - style: m.style.clone(), - halign: Default::default(), - valign: Default::default(), - }), - } - }) - .collect(), - ), + EntityKind::MText(m) => Some(explode_mtext(e, m)), // A region explodes into its boundary's lines and arcs. - EntityKind::Region(r) => Some( - r.segments() - .into_iter() - .map(|s| { - let k = match s { - Segment::Line(l) => line(l.a, l.b), - Segment::Arc { arc: a, .. } => arc(&a), - }; - Entity { handle: Handle(0), common: e.common.clone(), kind: k } - }) - .collect(), - ), - EntityKind::Dimension(dm) => { - // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. - let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - let g = cadcraft_render::dimension_in(d, dm); - let line_entity = |l: &Vec| { - if l.len() == 2 { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) - } else { - Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) }) - } - }; - // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. - let text_marks = &g.text_marks; - let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); - let mut v: Vec = marks(false).filter_map(&line_entity).collect(); - for t in &g.fills { - if let [a, b, c] = t.as_slice() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), - }); - } - } - // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and - // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). - if style.text_fill == 2 - && let [p0, p1, p2, p3] = g.text_fill.as_slice() - { - let fill = style.text_fill_color; - let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; - v.push(Entity { - handle: Handle(0), - common: cadcraft_doc::Common { color, ..e.common.clone() }, - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), - }); - } - v.extend(marks(true).filter_map(&line_entity)); - if !g.value.is_empty() { - v.push(Entity { - handle: Handle(0), - common: e.common.clone(), - kind: EntityKind::MText(cadcraft_doc::MText { - insert: v3(g.text_pos), - height: g.text_height, - width: 0.0, - attach: 5, - rotation: g.text_angle, - style: style.text_style.clone(), - contents: g.mtext_contents(), - line_spacing: 1.0, - line_spacing_exact: false, - columns: None, - }), - }); - } - Some(v) - } + EntityKind::Region(r) => Some(segment_entities(e, r.segments())), + EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)), _ => None, } diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index f48b6344e..1bfeb1b6c 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -465,6 +465,26 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } + /// The range of the lines swept, in this frame: the boundary's extent, cut to the view's + /// when there is one, and the view's x extent that spans are cut to. Spans are cut to the + /// view's extent only: they already lie within the boundary, and cutting them to its extent + /// too would move an end that sits on a boundary corner by a rounding error, so a view + /// covering the hatch would change its lines. `None` when the view misses the boundary. + fn lines_range(&self, bb: &Bounds2, view: Option<&Bounds2>) -> Option<(Vec2, Vec2, Option<(f64, f64)>)> { + let (mut lo, mut hi) = (bb.min, bb.max); + let mut span_cut = None; + if let Some(v) = view { + let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); + if vb.is_empty() || !vb.intersects(bb) { + return None; + } + lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); + hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); + span_cut = Some((vb.min.x, vb.max.x)); + } + Some((lo, hi, span_cut)) + } + /// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits. fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); @@ -472,21 +492,7 @@ impl Family<'_> { if bb.is_empty() { return; } - // The lines swept: the boundary's extent, cut to the view's (its corners in this frame) - // when there is one. Spans are cut to the view's extent only: they already lie within the - // boundary, and cutting them to its extent too would move an end that sits on a boundary - // corner by a rounding error, so a view covering the hatch would change its lines. - let (mut lo, mut hi) = (bb.min, bb.max); - let mut span_cut = None; - if let Some(v) = view { - let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); - if vb.is_empty() || !vb.intersects(&bb) { - return; - } - lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); - hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); - span_cut = Some((vb.min.x, vb.max.x)); - } + let Some((lo, hi, span_cut)) = self.lines_range(&bb, view) else { return }; let shift_per_line = self.delta.rotate(-self.ang).x; let k0 = (lo.y / self.spacing).floor() as i64; let k1 = (hi.y / self.spacing).ceil() as i64; @@ -504,17 +510,7 @@ impl Family<'_> { return; } let y = k as f64 * self.spacing; - let mut xs: Vec = Vec::new(); - for l in &local { - let n = l.len(); - for i in 0..n { - let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; - if (a.y > y) != (b.y > y) { - xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); - } - } - } - xs.sort_by(f64::total_cmp); + let xs = crossings(&local, y); let offset = shift_per_line * k as f64; for span in xs.chunks(2) { let [x0, x1] = span else { continue }; @@ -565,6 +561,22 @@ impl Family<'_> { } } +/// The x positions where the boundary loops cross the horizontal line at `y`, in order. +fn crossings(local: &[Vec], y: f64) -> Vec { + let mut xs: Vec = Vec::new(); + for l in local { + let n = l.len(); + for i in 0..n { + let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; + if (a.y > y) != (b.y > y) { + xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); + } + } + } + xs.sort_by(f64::total_cmp); + xs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index c545a1d28..32679e554 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -4,7 +4,8 @@ use std::sync::Arc; -use cadcraft_engine::doc::Handle; +use cadcraft_engine::cmd::file::compare::Comparison; +use cadcraft_engine::doc::{Drawing, Handle}; use cadcraft_render::DisplayList; use egui::{Rect, RichText, Stroke, vec2}; use serde_json::json; @@ -29,6 +30,23 @@ fn id() -> egui::Id { egui::Id::unique("cadcraft-compare-overlay") } +/// The display lists of the differences for this frame: the stored ones when they still match +/// `key`, otherwise rebuilt at zoom `scale` and stored. +fn cache_for(ctx: &egui::Context, key: (u64, u64, usize, usize, i64), doc: &Drawing, c: &Comparison, scale: f64) -> Cache { + let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); + if cache.key != key || cache.ours.is_none() { + let opts = cadcraft_render::Options { tolerance: 0.5 / scale.max(1e-12), ..Default::default() }; + let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); + let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); + let list = |d: &Drawing, hs: &[Handle]| { + Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) + }; + cache = Cache { key, ours: Some(list(doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; + ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); + } + cache +} + /// Draw the comparison over the canvas when it was made for the drawing and tab shown. pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf: &Xf, rect: Rect) { let t = Tokens::get(); @@ -38,17 +56,7 @@ pub(super) fn paint(app: &mut CadApp, ctx: &egui::Context, p: &egui::Painter, xf p.rect_filled(rect, 0.0, t.canvas.gamma_multiply(0.75)); let band = xf.scale.max(1e-300).log2().round() as i64; let key = (st.uid, st.revision, Arc::as_ptr(&c.other) as usize, c.differences(), band); - let mut cache: Cache = ctx.data_mut(|d| d.get_temp(id())).unwrap_or_default(); - if cache.key != key || cache.ours.is_none() { - let opts = cadcraft_render::Options { tolerance: 0.5 / xf.scale.max(1e-12), ..Default::default() }; - let ours: Vec = c.added.iter().copied().chain(c.changed.iter().map(|(a, _)| *a)).take(MAX_DRAWN).collect(); - let theirs: Vec = c.removed.iter().copied().chain(c.changed.iter().map(|(_, b)| *b)).take(MAX_DRAWN).collect(); - let list = |d: &cadcraft_engine::doc::Drawing, hs: &[Handle]| { - Arc::new(cadcraft_render::build_entities(d, hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.as_ref()), &opts)) - }; - cache = Cache { key, ours: Some(list(&st.doc, &ours)), theirs: Some(list(&c.other, &theirs)) }; - ctx.data_mut(|d| d.insert_temp(id(), cache.clone())); - } + let cache = cache_for(ctx, key, &st.doc, c, xf.scale); for (list, color) in [(&cache.theirs, t.compare_other), (&cache.ours, t.compare_this)] { if let Some(l) = list { let all: Vec = (0..l.prims.len()).collect(); diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index cfc1833f0..0a514720e 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -217,73 +217,15 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { /// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`. fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_files", |ui| { - ui.label(crate::tl!("Save new drawings as")); - ui.horizontal(|ui| { - for f in crate::SaveFormat::ALL { - let label = match f { - crate::SaveFormat::Dxf => crate::tl!("DXF"), - crate::SaveFormat::Dwg => crate::tl!("DWG"), - }; - if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { - changes.insert("saveFormat".into(), json!(f.as_str())); - } - } - }); + save_format_row(ui, app, changes); ui.end_row(); - - let version_caption = ui.label(crate::tl!("Default save version")); - let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); - let mut version = current; - let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { - for v in crate::SaveVersion::ALL { - ui.selectable_value(&mut version, v, v.year()); - } - }); - version_combo.response.labelled_by(version_caption.id); - if version != current { - changes.insert("saveVersion".into(), json!(version.year())); - } + save_version_row(ui, app, changes); ui.end_row(); - - ui.label(crate::tl!("Units of new drawings")); - ui.horizontal(|ui| { - let metric = app.session.settings.measureinit; - if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { - changes.insert("MEASUREINIT".into(), json!(0)); - } - if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { - changes.insert("MEASUREINIT".into(), json!(1)); - } - }); + units_row(ui, app, changes); ui.end_row(); - - let minutes_caption = ui.label(crate::tl!("Automatic save every")); - let mut minutes = cadcraft_engine::autosave::savetime(); - let max = cadcraft_engine::autosave::MAX_SAVETIME; - if ui - .add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))) - .labelled_by(minutes_caption.id) - .changed() - { - changes.insert("SAVETIME".into(), json!(minutes)); - } + autosave_row(ui, changes); ui.end_row(); - - let savefile_label = ui.label(crate::tl!("Automatic save file location")); - // Edited as text; the folder changes when the field loses focus. - let id = egui::Id::unique("cc_options_savefilepath"); - let current = cadcraft_engine::autosave::savefilepath(); - let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); - if r.changed() { - ui.data_mut(|d| d.insert_temp(id, path.clone())); - } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(id)); - if path.trim() != current { - changes.insert("SAVEFILEPATH".into(), json!(path.trim())); - } - } + savefile_row(ui, changes); ui.end_row(); }); hint(ui, crate::tl!("New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE).")); @@ -291,6 +233,76 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } +fn save_format_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Save new drawings as")); + ui.horizontal(|ui| { + for f in crate::SaveFormat::ALL { + let label = match f { + crate::SaveFormat::Dxf => crate::tl!("DXF"), + crate::SaveFormat::Dwg => crate::tl!("DWG"), + }; + if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { + changes.insert("saveFormat".into(), json!(f.as_str())); + } + } + }); +} + +fn save_version_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + let version_caption = ui.label(crate::tl!("Default save version")); + let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); + let mut version = current; + let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + for v in crate::SaveVersion::ALL { + ui.selectable_value(&mut version, v, v.year()); + } + }); + version_combo.response.labelled_by(version_caption.id); + if version != current { + changes.insert("saveVersion".into(), json!(version.year())); + } +} + +fn units_row(ui: &mut egui::Ui, app: &CadApp, changes: &mut Map) { + ui.label(crate::tl!("Units of new drawings")); + ui.horizontal(|ui| { + let metric = app.session.settings.measureinit; + if ui.radio(!metric, crate::tl!("Imperial (inches)")).clicked() && metric { + changes.insert("MEASUREINIT".into(), json!(0)); + } + if ui.radio(metric, crate::tl!("Metric (millimetres)")).clicked() && !metric { + changes.insert("MEASUREINIT".into(), json!(1)); + } + }); +} + +fn autosave_row(ui: &mut egui::Ui, changes: &mut Map) { + let minutes_caption = ui.label(crate::tl!("Automatic save every")); + let mut minutes = cadcraft_engine::autosave::savetime(); + let max = cadcraft_engine::autosave::MAX_SAVETIME; + if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).labelled_by(minutes_caption.id).changed() { + changes.insert("SAVETIME".into(), json!(minutes)); + } +} + +fn savefile_row(ui: &mut egui::Ui, changes: &mut Map) { + let savefile_label = ui.label(crate::tl!("Automatic save file location")); + // Edited as text; the folder changes when the field loses focus. + let id = egui::Id::unique("cc_options_savefilepath"); + let current = cadcraft_engine::autosave::savefilepath(); + let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, path.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if path.trim() != current { + changes.insert("SAVEFILEPATH".into(), json!(path.trim())); + } + } +} + /// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`. fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { let t = Tokens::get(); From d1cf06cbea7d6bd9f33385af3cbe5777581671a4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:50:25 -0400 Subject: [PATCH 89/91] refactor: split complex fillet, model, dimstyle and rectangle code into helpers (#2188) Pure extraction, no behaviour change: same order of side effects, same early returns and error values. Clears cognitive-complexity findings on tangent_circles, Model::build_budgeted, DimStyle::apply_fields and run_rectang2 (S3776), and replaces a redundant closure with a method reference in menus.rs (S1612). --- crates/constraints/src/model.rs | 137 +++++++++++++++++--------------- crates/doc/src/tables.rs | 78 ++++++++++-------- crates/engine/src/cmd/draw2.rs | 46 ++++++----- crates/geom/src/fillet.rs | 103 +++++++++++++++--------- crates/ui-egui/src/menus.rs | 2 +- 5 files changed, 209 insertions(+), 157 deletions(-) diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index 165afac8d..d18646c32 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -191,6 +191,77 @@ impl Model { Self::build_budgeted(d, handles, &mut members_left) } + /// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is + /// left out (too large, degenerate or unsupported). + fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option { + let shape = match kind { + EntityKind::Line(l) => { + m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); + Shape::Line + } + EntityKind::Circle(c) => { + m.x0.extend([c.center.x, c.center.y, c.radius]); + Shape::Circle + } + EntityKind::Arc(a) => { + m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]); + Shape::Arc + } + EntityKind::LwPolyline(p) => { + if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS { + return None; + } + for v in &p.vertices { + m.x0.extend([v.p.x, v.p.y]); + } + Shape::Poly { n: p.vertices.len(), closed: p.closed } + } + EntityKind::Point(p) => { + m.x0.extend([p.p.x, p.p.y]); + Shape::Point + } + EntityKind::Ellipse(el) => { + let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; + let arc = !ell.is_full(); + m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]); + if arc { + m.x0.extend([el.start, el.end]); + } + Shape::Ellipse { arc } + } + EntityKind::Spline(sp) => { + let fit = fit_mode(sp); + let pts = if fit { &sp.fit } else { &sp.control }; + let n = pts.len(); + if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) { + return None; + } + for p in pts { + m.x0.extend([p.x, p.y]); + } + // Fit points interpolate exactly, so the curve's ends are the first and last + // fit point; a control polygon's ends are its first and last points when the + // knots are clamped. + let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); + // Missing weights default to 1 in eval; only excess weights are unused. + let mut kept = sp.clone(); + kept.weights.truncate(kept.control.len()); + m.splines.insert(h, kept); + Shape::Spline { n, fit, ends } + } + EntityKind::Insert(ins) => { + let base = Vec2::new(ins.insert.x, ins.insert.y); + let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); + let n = pts.len(); + m.rigid.insert(h, pts); + Shape::Insert { n } + } + _ => return None, + }; + Some(shape) + } + /// Build solver parameters for supported entities while sharing rigid-member sampling work with other models. pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model { let mut m = Model::default(); @@ -200,71 +271,7 @@ impl Model { } let Some(e) = d.entity(h) else { continue }; let off = m.x0.len(); - let shape = match &e.kind { - EntityKind::Line(l) => { - m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]); - Shape::Line - } - EntityKind::Circle(c) => { - m.x0.extend([c.center.x, c.center.y, c.radius]); - Shape::Circle - } - EntityKind::Arc(a) => { - m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]); - Shape::Arc - } - EntityKind::LwPolyline(p) => { - if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS { - continue; - } - for v in &p.vertices { - m.x0.extend([v.p.x, v.p.y]); - } - Shape::Poly { n: p.vertices.len(), closed: p.closed } - } - EntityKind::Point(p) => { - m.x0.extend([p.p.x, p.p.y]); - Shape::Point - } - EntityKind::Ellipse(el) => { - let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; - let arc = !ell.is_full(); - m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]); - if arc { - m.x0.extend([el.start, el.end]); - } - Shape::Ellipse { arc } - } - EntityKind::Spline(sp) => { - let fit = fit_mode(sp); - let pts = if fit { &sp.fit } else { &sp.control }; - let n = pts.len(); - if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) { - continue; - } - for p in pts { - m.x0.extend([p.x, p.y]); - } - // Fit points interpolate exactly, so the curve's ends are the first and last - // fit point; a control polygon's ends are its first and last points when the - // knots are clamped. - let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true))); - // Missing weights default to 1 in eval; only excess weights are unused. - let mut kept = sp.clone(); - kept.weights.truncate(kept.control.len()); - m.splines.insert(h, kept); - Shape::Spline { n, fit, ends } - } - EntityKind::Insert(ins) => { - let base = Vec2::new(ins.insert.x, ins.insert.y); - let pts: Vec = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); - m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]); - let n = pts.len(); - m.rigid.insert(h, pts); - Shape::Insert { n } - } - _ => continue, - }; + let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue }; if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) { m.x0.truncate(off); continue; diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 328634831..a2c63ca6a 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -441,6 +441,49 @@ impl DimStyle { .or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name))) } + /// The value `v` takes for a field whose current value is `old`: coerced to the field's type where + /// the text or number can be read as it. + fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value { + use serde_json::Value; + match (old, v) { + (Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)), + (Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")), + (Value::Number(_), Value::String(t)) => match t.trim().parse::() { + Ok(x) => serde_json::json!(x), + // Lineweight names. + Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() { + "bylayer" => serde_json::json!(-1), + "byblock" => serde_json::json!(-2), + "default" => serde_json::json!(-3), + _ => v.clone(), + }, + Err(_) => v.clone(), + }, + (Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)), + (Value::String(_), Value::Number(n)) => Value::String(n.to_string()), + (Value::Object(_), Value::Number(n)) => { + let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci); + c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()) + } + (Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()), + _ => v.clone(), + } + } + + /// Whether `coerced` is outside the range its `field` can take. + fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool { + let x = coerced.as_f64(); + match field { + "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), + "scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0), + "fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0), + "textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0), + "dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)), + "jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)), + _ => false, + } + } + /// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as /// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or /// had unusable values. @@ -458,29 +501,7 @@ impl DimStyle { rejected.push(k.clone()); continue; }; - let coerced = match (&old, v) { - (Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)), - (Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")), - (Value::Number(_), Value::String(t)) => match t.trim().parse::() { - Ok(x) => serde_json::json!(x), - // Lineweight names. - Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() { - "bylayer" => serde_json::json!(-1), - "byblock" => serde_json::json!(-2), - "default" => serde_json::json!(-3), - _ => v.clone(), - }, - Err(_) => v.clone(), - }, - (Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)), - (Value::String(_), Value::Number(n)) => Value::String(n.to_string()), - (Value::Object(_), Value::Number(n)) => { - let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci); - c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()) - } - (Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()), - _ => v.clone(), - }; + let coerced = Self::coerce_value(&old, v, field); // Integers stored in u8 fields: round floats. let coerced = match (&old, &coerced) { // Lineweights (i16): whole hundredths of a mm or -1/-2/-3. @@ -502,16 +523,7 @@ impl DimStyle { _ => coerced, }; // Sizes that can't be negative (text height and tolerance scale: not zero either). - let x = coerced.as_f64(); - let out_of_range = match field { - "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), - "scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0), - "fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0), - "textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0), - "dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)), - "jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)), - _ => false, - }; + let out_of_range = Self::out_of_range(field, &coerced); if out_of_range { rejected.push(k.clone()); continue; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index c174994c1..4f52bbef5 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1585,25 +1585,10 @@ fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f k } -/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is -/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`. -pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { - let a = point_req("rectang", p, "p1")?; - // Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly. - let centered = bool_or(p, "center", false); - let rot = f64_or(p, "rotation", 0.0).to_radians(); - let ch = p - .get("chamfer") - .and_then(Value::as_array) - .map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0))) - .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) - .unwrap_or((0.0, 0.0)); - if ch.0 < 0.0 || ch.1 < 0.0 { - return Err(bad("rectang", "`chamfer` distances must be zero or positive")); - } - let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0); - let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0); - let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) { +/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with +/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`. +fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> { + Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) { let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; let (sx, sy) = match point_param(p, "p2") { @@ -1628,7 +1613,28 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { let loc = (b - a).rotate(-rot); // In centre mode the picked point is one corner, mirrored across the centre. if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) } - }; + }) +} + +/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is +/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`. +pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { + let a = point_req("rectang", p, "p1")?; + // Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly. + let centered = bool_or(p, "center", false); + let rot = f64_or(p, "rotation", 0.0).to_radians(); + let ch = p + .get("chamfer") + .and_then(Value::as_array) + .map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0))) + .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) + .unwrap_or((0.0, 0.0)); + if ch.0 < 0.0 || ch.1 < 0.0 { + return Err(bad("rectang", "`chamfer` distances must be zero or positive")); + } + let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0); + let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0); + let (w, h) = rect_size(p, a, rot, centered)?; if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() { return Err(bad("rectang", "rectangle has no area")); } diff --git a/crates/geom/src/fillet.rs b/crates/geom/src/fillet.rs index d31757f79..78e1fc93b 100644 --- a/crates/geom/src/fillet.rs +++ b/crates/geom/src/fillet.rs @@ -236,6 +236,46 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V if !(r.is_finite() && r >= 0.0) { return Vec::new(); } + let Some(win) = search_window(c1, c2, near) else { return Vec::new() }; + let size = win.width().max(win.height()).max(r).max(1e-9); + let win = win.expand(2.0 * r + 0.25 * size); + let tol = 1e-9 * size.max(1.0); + let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; + let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; + let mut out: Vec = Vec::new(); + let mut work = 16_000_000usize; + for (s1, s2) in signs { + let (d1, d2) = (s1 * r, s2 * r); + let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); + let search = TanSearch { c1, r1: &r1, d1, c2, r2: &r2, d2, tol }; + // Refine each pair before visiting the next: raw duplicate seeds in an early run must + // not consume all candidate slots. A separate request-wide budget bounds actual work. + for a in &o1 { + for b in &o2 { + if !refine_pair(&search, a, b, &mut work, &mut out) { + return out; + } + } + } + } + out +} + +/// The two curves of one tangent search for one sign choice: their ranges and offsets, and the +/// tolerance. +struct TanSearch<'a> { + c1: &'a TanCurve, + r1: &'a Range, + d1: f64, + c2: &'a TanCurve, + r2: &'a Range, + d2: f64, + tol: f64, +} + +/// The window the tangent search looks in: the pick points and the extents of the curves that +/// are not lines. `None` when it is empty or not finite. +fn search_window(c1: &TanCurve, c2: &TanCurve, near: &[Vec2]) -> Option { let mut win = Bounds2::from_points(near.iter().copied().filter(|p| p.is_finite())); for c in [c1, c2] { match c { @@ -251,47 +291,34 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V } } if win.is_empty() || !(win.width().is_finite() && win.height().is_finite()) { - return Vec::new(); + return None; } - let size = win.width().max(win.height()).max(r).max(1e-9); - let win = win.expand(2.0 * r + 0.25 * size); - let tol = 1e-9 * size.max(1.0); - let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; - let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; - let mut out: Vec = Vec::new(); - let mut work = 16_000_000usize; - for (s1, s2) in signs { - let (d1, d2) = (s1 * r, s2 * r); - let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); - // Refine each pair before visiting the next: raw duplicate seeds in an early run must - // not consume all candidate slots. A separate request-wide budget bounds actual work. - for a in &o1 { - for b in &o2 { - let mut seeds = Vec::new(); - crossings(a, b, &mut seeds, &mut work); - seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); - seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9); - for seed in seeds { - // Newton refinement has a fixed iteration cap and several curve evaluations. - let Some(left) = work.checked_sub(128) else { return out }; - work = left; - let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue }; - let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; - let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); - if !out.iter().any(near_dup) { - out.push(CornerFit { t1, t2, p1, p2, center }); - } - if out.len() >= MAX_SOLUTIONS { - return out; - } - } - if work == 0 { - return out; - } - } + Some(win) +} + +/// Refine the crossings of the samples `a` and `b` into circles, adding the distinct ones to +/// `out`. `false` when the work budget or the solution cap ends the search. +fn refine_pair(search: &TanSearch, a: &[(f64, Vec2)], b: &[(f64, Vec2)], work: &mut usize, out: &mut Vec) -> bool { + let mut seeds = Vec::new(); + crossings(a, b, &mut seeds, work); + seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); + seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9); + for seed in seeds { + // Newton refinement has a fixed iteration cap and several curve evaluations. + let Some(left) = work.checked_sub(128) else { return false }; + *work = left; + let TanSearch { c1, r1, d1, c2, r2, d2, tol } = *search; + let Some((t1, t2)) = refine(c1, r1, d1, c2, r2, d2, seed, tol) else { continue }; + let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; + let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); + if !out.iter().any(near_dup) { + out.push(CornerFit { t1, t2, p1, p2, center }); + } + if out.len() >= MAX_SOLUTIONS { + return false; } } - out + *work != 0 } #[cfg(test)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 480d5563d..40115388b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -172,7 +172,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Date: Sun, 25 Oct 2026 21:58:11 -0400 Subject: [PATCH 90/91] macOS Edit menu keys edit a focused text field (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS Edit menu keys edit a focused text field The menu bar takes ⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z before any window sees them, so they ran Copy, Paste or Undo on the drawing while the user was typing in a field. While a text field has the keyboard, in the main window or a floating panel's own window, the menu now hands the field its key instead. craft-libs is pinned at #318's head for typing_window and send_keys. * Edit menu items a text field takes stay enabled while one has the keyboard Edit ▸ Paste follows the drawing clipboard, so with nothing copied in the drawing it was greyed out even while a text field could paste. --- apps/cadcraft/src/native_menu.rs | 5 +- crates/ui-egui/src/menus.rs | 55 ++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/native_menu_text_keys.rs | 65 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/native_menu_text_keys.rs diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 1e987870d..8649ed8e4 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -118,7 +118,7 @@ impl NativeMenu { if cmd == "ui.discord" { ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); } else { - menus::activate(app, &cmd.clone()); + menus::activate_native(app, ctx, &cmd.clone()); } ctx.request_repaint(); } @@ -142,8 +142,9 @@ impl NativeMenu { return; } self.last_refresh = now; + let typing = menus::text_field_window(ctx).is_some(); for (cmd, _, item) in self.items.values() { - let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); + let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); item.set_enabled(en); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 40115388b..43b135d19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -649,6 +649,61 @@ pub fn activate(app: &mut CadApp, id: &str) { app.start(id); } +/// Activate the menu command `id` from a native menu bar, which takes the Edit menu's key +/// equivalents (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z, ⇧⌘Z) before any window sees them: while a text field has +/// the keyboard, in the main window or a floating panel's own window, the field gets the key +/// and the drawing command does not run. +pub fn activate_native(app: &mut CadApp, ctx: &egui::Context, id: &str) { + let (Some(window), Some(text)) = (text_field_window(ctx), text_command(id, ctx.os().is_mac())) else { + activate(app, id); + return; + }; + match text { + TextCommand::Paste => ctx.send_viewport_cmd_to(window, egui::ViewportCommand::RequestPaste), + TextCommand::Events(events) if window == egui::ViewportId::ROOT => { + app.synthetic.extend(events); + ctx.request_repaint(); + } + TextCommand::Events(events) => craft_ui::docking::send_keys(ctx, window, events), + } +} + +/// The window whose text field has the keyboard: a floating panel's own OS window, or the main +/// window. +pub fn text_field_window(ctx: &egui::Context) -> Option { + craft_ui::docking::typing_window(ctx).or_else(|| ctx.text_edit_focused().then_some(egui::ViewportId::ROOT)) +} + +/// Whether a focused text field takes the menu command `id` ([`activate_native`]): a native +/// menu keeps such items enabled while one has the keyboard, whatever the drawing allows. +pub fn edits_text(id: &str) -> bool { + text_command(id, false).is_some() +} + +/// What a focused text field does for an Edit menu command. +#[derive(Debug, PartialEq)] +enum TextCommand { + /// The clipboard text, which the window asks for and receives as `Paste`. + Paste, + Events(Vec), +} + +fn text_command(id: &str, mac: bool) -> Option { + let key = |key, shift| { + let modifiers = egui::Modifiers { mac_cmd: mac, ctrl: !mac, command: true, shift, alt: false }; + TextCommand::Events([true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into()) + }; + Some(match id { + "copyclip" => TextCommand::Events(vec![egui::Event::Copy]), + "cutclip" => TextCommand::Events(vec![egui::Event::Cut]), + "pasteclip" => TextCommand::Paste, + "u" => key(egui::Key::Z, false), + "redo" => key(egui::Key::Z, true), + "selectall" => key(egui::Key::A, false), + _ => return None, + }) +} + /// Run `id` with default parameters without cancelling the active command, as the drafting /// toggles (F3, F7-F12, the status bar buttons) are in AutoCAD. pub fn run_transparent(app: &mut CadApp, id: &str) { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 5faf3135b..c908bc9e2 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -17,6 +17,7 @@ mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod native_menu_text_keys; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/native_menu_text_keys.rs b/crates/ui-egui/tests/it/native_menu_text_keys.rs new file mode 100644 index 000000000..f68e5828e --- /dev/null +++ b/crates/ui-egui/tests/it/native_menu_text_keys.rs @@ -0,0 +1,65 @@ +//! The macOS menu bar takes the Edit menu's key equivalents before the window does: with a text +//! field focused they edit the field, and only without one do they run the drawing command. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui::{accesskit::Role, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.ui.start_tab = false; + app.ui.in_window_menu = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(5); + h +} + +/// A native menu choice, then the frames that deliver what it queued for the main window. +fn menu(h: &mut Harness<'_, CadApp>, id: &str) { + let ctx = h.ctx.clone(); + menus::activate_native(h.state_mut(), &ctx, id); + let mut raw = egui::RawInput::default(); + h.state_mut().raw_input_hook(&ctx, &mut raw); + h.input_mut().events.extend(raw.events); + h.run_steps(3); +} + +fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(Role::Tab, "QuickCalc").parent().unwrap().get_all_by_role(Role::TextInput).next().unwrap() +} + +#[test] +fn edit_menu_keys_edit_the_focused_text_field_and_otherwise_the_drawing() { + let mut h = harness(); + h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let drawing = h.state().session.doc().unwrap().clone(); + menus::activate(h.state_mut(), "ui.toggle.quickcalc"); + h.run_steps(5); + expression(&h).click(); + h.run_steps(3); + expression(&h).type_text("2+3"); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), Some(egui::ViewportId::ROOT)); + assert!(menus::edits_text("pasteclip") && !menus::edits_text("line")); + menu(&mut h, "selectall"); + menu(&mut h, "cutclip"); + assert_eq!(expression(&h).value().as_deref(), Some(""), "Select All and Cut act on the field"); + assert!(h.state().session.running.is_none()); + assert_eq!(h.state().session.doc().unwrap(), &drawing); + + h.ctx.memory_mut(|m| m.stop_text_input()); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), None); + menu(&mut h, "u"); + assert_ne!(h.state().session.doc().unwrap(), &drawing, "without a focused field, Undo undoes the line"); +} From d3ae8a6af0b76185e33f6dc2735679fa5c2be77c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:23:01 -0400 Subject: [PATCH 91/91] Side panels dock in left and right dock columns (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pin craft-libs at craft-libs#312's head (4dc543c) for the dock-column move * Side panels dock in left and right dock columns Tool Sets sits in a left dock column and Layers above Properties in a right one, at the widths of the old side frames. Palettes dock only into those columns: a window edge without a dock makes one, and neither the drawing nor the top and bottom edges take a drop. A palette opened with no recorded place goes into its dock group (External References below Properties). Iconic columns show CADCraft's own code-drawn icons. * Workspace file: dock layout version; split-frame layouts reset to the default The workspace file saves dockingVersion next to the panel layout. A layout saved without it, or with an older one, is dropped with its hidden panels on load; the other workspace settings are kept. * Floating panels open in their own OS windows on the desktop - craft-ui's native-windows feature is on for the desktop app (not the web build) and for the UI crate's tests. - While the Save changes prompt, Drawing Recovery or another egui modal is up, the panels' OS windows take no input. - Keys, text and clipboard events typed in a panel's OS window go into the main window's next input, so shortcuts, the command line and the canvas get them. - Files dropped on a panel's OS window open like files dropped on the main window. * Floating windows stay below the macOS title row A drag in that row moves the whole app window, so a floating panel drawn over it could not be moved or used. The dock area keeps floating windows, flyouts and drag ghosts below it. * Opening a floating panel from a menu or command brings its window to the front * Workspace saves are spaced at least 500 ms apart, so dragging a floating window doesn't write the file every frame * Pin craft-libs at #312's head b6d35e28 * Pin craft-libs at #313's head cdc67131 (result-rectangle drop preview) * The title row is craft-window's title bar Its empty part moves the window and follows the macOS double-click setting, and does nothing in full screen. On macOS the window's movable flag follows the pointer, so AppKit's title-band drag leaves the window still under the row's controls and under floating panels. Floating panels may therefore go over the title row again. craft-window is pinned at craft-libs#316's head, which is not stacked on the craft-ui pin. * Pin craft-libs at 189b4ffe (#313 and #316 merged) * macOS menu choices and their shortcuts run at once A menu choice is no window event, so the app ran it only at its next frame for another reason: File ▸ Close (Cmd+W) on an edited drawing showed "Save changes?" and dimmed the floating palettes a second or two late. The menu's event handler now wakes the app. * Pin craft-libs at pin-round3 d24e4bcc * Cargo.lock and the native-window test helper after the egui 0.37 merge * Pin craft-libs at #334 (4638095): the title bar's empty area takes no keyboard focus * Pin craft-libs at main 271594c0 * A modifier the main window changes while panel keys are forwarded is the one put back --- ATTRIBUTION.md | 8 +- Cargo.lock | 28 +- Cargo.toml | 15 +- NOTICE | 4 +- apps/cadcraft/Cargo.toml | 5 + apps/cadcraft/src/main.rs | 23 +- apps/cadcraft/src/workspace.rs | 126 ++++--- crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/chrome.rs | 12 +- crates/ui-egui/src/docking.rs | 242 ++++++++++--- crates/ui-egui/src/lib.rs | 61 +++- crates/ui-egui/src/xrefs.rs | 15 +- crates/ui-egui/tests/it/docking_workflows.rs | 347 ++++++++++++++----- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/native_windows.rs | 116 +++++++ 15 files changed, 783 insertions(+), 223 deletions(-) create mode 100644 crates/ui-egui/tests/it/native_windows.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 54439c12e..e124d102a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | -| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | -| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | -| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | -| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 4dd434595..b646729fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,7 +571,9 @@ dependencies = [ "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "craft-ui", "craft-update", + "craft-window", "eframe", "egui", "hidapi", @@ -740,6 +742,7 @@ dependencies = [ "chrono", "craft-ui", "craft-update", + "craft-window", "egui", "egui-wgpu", "egui_kittest", @@ -992,12 +995,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-syntax", "krilla", @@ -1007,7 +1010,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "egui", "log", @@ -1020,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "chrono", "dunce", @@ -1042,6 +1045,17 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1981,7 +1995,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-ccitt", ] @@ -1995,7 +2009,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2357,7 +2371,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 9ceb02e4d..db0d88092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false } # foundation thiserror = "2" @@ -53,14 +53,15 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] } egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = "0.37" eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -90,9 +91,9 @@ debug = 0 # PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself # (Cargo ignores dependency-local patches). [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } # Keep the complete egui family and native event bridge on reviewed fork commits. ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 3e6cc03a3..2eb24b221 100644 --- a/NOTICE +++ b/NOTICE @@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc -(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc). +(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc). CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ -Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE +Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts and registered through egui's default font definitions. They derive from emoji-icon-font by diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 4c88f2ad6..48c2c2d15 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -34,11 +34,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page. +craft-ui = { workspace = true, features = ["native-windows"] } craft-update = { workspace = true } rfd = { workspace = true } sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] +# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's +# controls and under floating panels. +craft-window = { workspace = true } muda = { version = "0.21", default-features = false } # View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion # (system_animations.rs). Already in Cargo.lock through muda. diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 45852da2f..1007fc0c1 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -39,11 +39,22 @@ struct App { workspace_error: Option, #[cfg(target_os = "macos")] native_menu: Option, + /// The `movable` flag last set on the main window. + #[cfg(target_os = "macos")] + movable: Option, } impl App { - fn persist_workspace(&mut self, final_attempt: bool) { - let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) }; + /// `ctx` is `None` only for the final attempt at exit. + fn persist_workspace(&mut self, ctx: Option<&egui::Context>) { + let result = match ctx { + None => self.workspace.save_on_exit(&self.cad.ui), + Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| { + if let Some(wait) = wait { + ctx.request_repaint_after(wait); + } + }), + }; if let Err(error) = result { if self.workspace_error.as_ref() != Some(&error) { eprintln!("CADCraft workspace: {error}"); @@ -86,7 +97,7 @@ impl eframe::App for App { } self.spacemouse.poll(&mut self.cad, ctx); // After `cad.logic`, so a control command's workspace change is saved in the same frame. - self.persist_workspace(false); + self.persist_workspace(Some(ctx)); } if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -100,11 +111,13 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.cad.ui(ui); + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); } /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a /// crash leaves them for recovery). fn on_exit(&mut self) { - self.persist_workspace(true); + self.persist_workspace(None); self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } @@ -405,6 +418,8 @@ fn main() -> eframe::Result { workspace_error, #[cfg(target_os = "macos")] native_menu: None, + #[cfg(target_os = "macos")] + movable: None, })) }), ) diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index f4e34d4fb..b8e541ed3 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -5,11 +5,15 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; use cadcraft_ui_egui::UiState; use serde_json::{Value, json}; const MAX_BYTES: u64 = 64 * 1024; +/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are +/// spaced at least this far apart, and the newest state is written when the interval ends. +const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500); static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); pub struct Workspace { @@ -17,6 +21,7 @@ pub struct Workspace { last_attempt: Option, read_failed: bool, pending_write_error: Option, + last_write: Option, } fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option { @@ -44,6 +49,7 @@ fn snapshot(ui: &UiState) -> Result { } Ok(json!({ "docking": ui.docking, + "dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION, "showToolsets": ui.show_toolsets, "showPalettes": ui.show_palettes, "showToolbar": ui.show_toolbar, @@ -80,10 +86,16 @@ fn read(path: &Path) -> Result, String> { value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?; // Dock schemas can evolve independently of the other saved workspace preferences. // Recover only this optional field; runtime UiState deserialization remains strict. + // A layout from before the current shape (split frames, saved without `dockingVersion`) + // gives way to the default, its hidden panels with it. let mut fields = workspace.clone(); - let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking")); + let (docking, version) = match fields.as_object_mut() { + Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")), + None => (None, None), + }; + let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION)); let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; - if let Some(docking) = docking + if let Some(docking) = docking.filter(|_| current) && let Ok(docking) = serde_json::from_value::(docking) && cadcraft_ui_egui::docking::validate(&docking).is_ok() { @@ -136,38 +148,47 @@ impl Workspace { match result { Ok(ui) => { let initial = ui.as_ref().cloned().unwrap_or_default(); - (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None) + (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None) } Err(error) => ( - Self { path, last_attempt: None, read_failed: true, pending_write_error: None }, + Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None }, None, Some(format!("Workspace could not be restored; its file was left unchanged. {error}")), ), } } - pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> { - let Some(path) = &self.path else { return Ok(()) }; + /// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change + /// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it. + pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result, String> { + let Some(path) = &self.path else { return Ok(None) }; if self.read_failed { // Preserve a corrupt, oversized or newer-version file until the user repairs it. - return Ok(()); + return Ok(None); } let value = snapshot(ui)?; if self.last_attempt.as_ref() == Some(&value) { - return self.pending_write_error.clone().map_or(Ok(()), Err); + return self.pending_write_error.clone().map_or(Ok(None), Err); + } + if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last))) + && !wait.is_zero() + { + return Ok(Some(wait)); } // Keep a failed state's warning without retrying disk I/O every rendered frame. self.last_attempt = Some(value.clone()); + self.last_write = Some(now); let result = write(path, &value); self.pending_write_error = result.as_ref().err().cloned(); - result + result.map(|()| None) } pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> { if self.pending_write_error.is_some() { self.last_attempt = None; } - self.save_if_changed(ui) + self.last_write = None; + self.save_if_changed(ui, Instant::now()).map(|_| ()) } } @@ -216,7 +237,7 @@ mod tests { start_tab: true, ..Default::default() }; - first.save_if_changed(&ui).unwrap(); + first.save_if_changed(&ui, Instant::now()).unwrap(); let bytes = fs::read(&path).unwrap(); let value: Value = serde_json::from_slice(&bytes).unwrap(); assert!(value["workspace"].get("dialog").is_none()); @@ -229,37 +250,42 @@ mod tests { let mut transient_only = ui.clone(); transient_only.dialog = None; transient_only.start_tab = false; - first.save_if_changed(&transient_only).unwrap(); + first.save_if_changed(&transient_only, Instant::now()).unwrap(); assert_eq!(fs::read(&path).unwrap(), bytes); fs::remove_dir_all(directory).unwrap(); } #[test] - fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { - use cadcraft_ui_egui::docking::{Panel, permissions}; + fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() { + use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel}; let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); let mut ui = UiState::default(); let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); - app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); + app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap(); + app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap(); ui.docking = app.ui.docking; ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap(); ui.show_toolsets = false; - store.save_if_changed(&ui).unwrap(); + store.save_if_changed(&ui, Instant::now()).unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION)); let (_, reloaded, error) = Workspace::at(Some(path)); assert!(error.is_none()); let mut reloaded = reloaded.unwrap(); assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); - reloaded.docking.set_visible(Panel::ToolSets, true, None, permissions).unwrap(); - assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); + assert!(!reloaded.show_toolsets); + reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap(); + let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}"); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); fs::remove_dir_all(directory).unwrap(); } #[test] - fn version_one_docking_file_still_loads() { - use cadcraft_ui_egui::docking::{Panel, reveal_panel}; + fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() { + use cadcraft_ui_egui::docking::default_workspace; // Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a // floating window as one `{panels, active, rect}` group, Tool Sets closed. let file = r#"{"version":1,"workspace":{ @@ -271,28 +297,18 @@ mod tests { "floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]}, "hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"}, "size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]}, - "showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#; + "showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#; let directory = temp_dir(); let path = directory.join("workspace.json"); fs::write(&path, file).unwrap(); let (_, restored, error) = Workspace::at(Some(path)); assert!(error.is_none(), "{error:?}"); - let mut ui = restored.unwrap(); + let ui = restored.unwrap(); assert_eq!(ui.toolset_tab, "Modeling"); - let docking = &ui.docking; - assert_eq!(docking.layout.floating.len(), 1); - let window = &docking.layout.floating[0]; - assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]); - assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}})); - assert_eq!( - serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"], - json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}) - ); - assert!(!docking.layout.contains(&Panel::ToolSets)); - reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap(); - let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap(); - assert_eq!(tools.anchor, Some(Panel::Canvas)); - assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}])); + assert_eq!(ui.history_lines, 4); + assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar); + assert_eq!(ui.docking.layout, default_workspace().layout); + assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden); fs::remove_dir_all(directory).unwrap(); } @@ -311,7 +327,7 @@ mod tests { let (mut store, ui, error) = Workspace::at(Some(path.clone())); assert!(ui.is_none()); assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged"))); - store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap(); + store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap(); assert_eq!(fs::read(&path).unwrap(), bytes); } fs::remove_dir_all(directory).unwrap(); @@ -326,7 +342,8 @@ mod tests { json!({"layout":false,"hidden":[]}), json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}), ] { - let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking}); + let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking, + "dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION}); assert!(serde_json::from_value::(workspace.clone()).is_err(), "runtime state must stay strict"); fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap(); let (_, restored, error) = Workspace::at(Some(path.clone())); @@ -342,16 +359,41 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn rapid_changes_are_written_once_per_interval_with_the_newest_state() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + let start = Instant::now(); + let mut ui = UiState { history_lines: 1, ..Default::default() }; + assert_eq!(store.save_if_changed(&ui, start).unwrap(), None); + // A drag: a new state every frame, none of them written until the interval ends. + for (frame, lines) in (2..=5).enumerate() { + ui.history_lines = lines; + let now = start + Duration::from_millis(16 * (frame as u64 + 1)); + let wait = store.save_if_changed(&ui, now).unwrap(); + assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start))); + } + assert_eq!(read(&path).unwrap().unwrap().history_lines, 1); + assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 5); + ui.history_lines = 6; + assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some()); + store.save_on_exit(&ui).unwrap(); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 6); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn invalid_update_cannot_replace_last_good_workspace() { let directory = temp_dir(); let path = directory.join("workspace.json"); let (mut store, _, _) = Workspace::at(Some(path.clone())); let mut ui = UiState { show_palettes: false, ..Default::default() }; - store.save_if_changed(&ui).unwrap(); + store.save_if_changed(&ui, Instant::now()).unwrap(); let good = fs::read(&path).unwrap(); ui.collapsed_groups = vec!["x".repeat(257)]; - assert!(store.save_if_changed(&ui).is_err()); + assert!(store.save_if_changed(&ui, Instant::now()).is_err()); assert_eq!(fs::read(&path).unwrap(), good); fs::remove_dir_all(directory).unwrap(); } @@ -364,9 +406,9 @@ mod tests { // Simulate a destination becoming unavailable after startup. fs::create_dir(&path).unwrap(); let ui = UiState { show_palettes: false, ..Default::default() }; - let first_error = store.save_if_changed(&ui).unwrap_err(); + let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err(); fs::remove_dir(&path).unwrap(); - assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error); + assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error); assert!(!path.exists(), "ordinary frames must not retry disk writes"); store.save_on_exit(&ui).unwrap(); assert!(!read(&path).unwrap().unwrap().show_palettes); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aee4c47f7..d21c2d8ca 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,7 @@ cadcraft-doc = { workspace = true } cadcraft-render = { workspace = true } cadcraft-engine = { workspace = true } cadcraft-fonts = { workspace = true } +craft-window = { workspace = true } egui = { workspace = true } # GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature). egui-wgpu = { workspace = true } @@ -31,6 +32,8 @@ web-time = { workspace = true } craft-update = { workspace = true } [dev-dependencies] +# Tests build the dock area as the desktop app does (headless contexts keep floats in-window). +craft-ui = { workspace = true, features = ["native-windows"] } # The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in # production too; the tests exercise the same installed chain as the app. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0e79f8444..f3a935d79 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -69,6 +69,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); if title_h > 0.0 { + // The empty row moves the window; it goes first so later widgets take their own clicks. + craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h))); let title = match app.session.state() { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), _ => format!("CADCraft {}", crate::tl!("Start")), @@ -78,16 +80,6 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim); let x = title_left(r.left(), r.width(), galley.size().x); ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); - // Allow dragging the window by the title row. - let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h)); - let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG); - if resp.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if resp.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } } if tb_h > 0.0 { let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h)); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6c1e624b5..bbbf08902 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock, - Zone, + Action, Column, DockArea, DockCommand, DockContent, DockHeights, DockOperation, DockStyle, FloatSpace, Group, Layout, Location, Node, + PanelLimits, Permissions, Placement, Side, Sides, Target, UiDock, Zone, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; @@ -43,6 +43,18 @@ impl Panel { Self::ExternalRefs => "External References", } } + /// The icon a palette shows in a column collapsed to icons. + pub fn icon(self) -> Option { + use crate::icons::Icon; + match self { + Self::Canvas => None, + Self::ToolSets => Some(Icon::Switcher), + Self::QuickCalc => Some(Icon::Table), + Self::Layers => Some(Icon::Layers), + Self::Properties => Some(Icon::Properties), + Self::ExternalRefs => Some(Icon::Attach), + } + } /// Palettes outside the default arrangement, opened on request (QUICKCALC). pub const ON_DEMAND: &[Self] = &[Self::QuickCalc]; } @@ -63,31 +75,106 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right. +/// The panel layout's shape version, saved beside it. Layouts saved without it, or with an +/// older one, are the split frames from before the dock columns and give way to the default. +pub const LAYOUT_VERSION: u32 = 2; + +/// Width of the left dock's Tool Sets column. +pub const TOOL_SETS_WIDTH: f32 = 220.0; +/// Width of the right dock's palettes column. +pub const PALETTES_WIDTH: f32 = 300.0; +/// Layers' body height above Properties in a dock column. +const LAYERS_HEIGHT: f32 = 280.0; + +fn dock(side: Side, width: f32, groups: &[Panel]) -> Node { + let groups = groups.iter().map(|panel| Group::new(vec![*panel], 0)).collect(); + Node::Dock { side, columns: vec![Column::new(groups, width)] } +} + +/// Tool Sets in the left dock, the drawing, and Layers above Properties in the right dock. pub fn default_workspace() -> Workspace { Workspace::new(Layout { root: Some(split( SplitAxis::Horizontal, - SplitSize::FixedFirst(220.0), - tabs(Panel::ToolSets), + SplitSize::FixedFirst(TOOL_SETS_WIDTH), + dock(Side::Start, TOOL_SETS_WIDTH, &[Panel::ToolSets]), split( SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), + SplitSize::FixedSecond(PALETTES_WIDTH), tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, + dock(Side::End, PALETTES_WIDTH, &[Panel::Layers, Panel::Properties]), ), )), floating: Vec::new(), }) } +/// The dock a panel belongs in: Tool Sets on the left, the palettes on the right. +fn home_side(panel: Panel) -> (Side, f32) { + if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) } +} + +/// The panels of every dock group on `side`: columns from the window edge inwards, groups from +/// top to bottom. +fn dock_groups(layout: &Layout, side: Side) -> Vec>> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = nodes.pop() { + seen = seen.saturating_add(1); + if seen > 4 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +fn docked(layout: &Layout, panel: Panel) -> bool { + [Side::Start, Side::End].into_iter().any(|side| dock_groups(layout, side).iter().flatten().flatten().any(|p| *p == panel)) +} + +/// The docked neighbour a panel with no recorded place opens beside, as a new group above +/// (`false`) or below (`true`) it: Layers above Properties, External References and QuickCalc +/// below them, Tool Sets at the top of the left dock. +fn home_anchor(layout: &Layout, panel: Panel) -> Option<(Panel, bool)> { + let neighbours: &[(Panel, bool)] = match panel { + Panel::Canvas | Panel::ToolSets => &[], + Panel::Layers => &[(Panel::Properties, false), (Panel::ExternalRefs, false)], + Panel::Properties => &[(Panel::Layers, true), (Panel::ExternalRefs, false)], + Panel::ExternalRefs => &[(Panel::Properties, true), (Panel::Layers, true)], + Panel::QuickCalc => &[(Panel::ExternalRefs, true), (Panel::Properties, true), (Panel::Layers, true)], + }; + if let Some(found) = neighbours.iter().copied().find(|(neighbour, _)| docked(layout, *neighbour)) { + return Some(found); + } + let (side, _) = home_side(panel); + let columns = dock_groups(layout, side); + let column = columns.first()?; + if panel == Panel::ToolSets { + column.first().and_then(|group| group.first()).map(|anchor| (*anchor, false)) + } else { + column.last().and_then(|group| group.first()).map(|anchor| (*anchor, true)) + } +} + +/// The actions that open a panel with no recorded place in its dock: a new group beside its +/// docked neighbour, else a new dock on its side at its default width. +fn home(layout: &Layout, panel: Panel) -> Vec> { + if let Some((anchor, below)) = home_anchor(layout, panel) { + return vec![Action::OpenAt { panel, anchor, placement: Placement::Group { below, height: None } }]; + } + let (side, width) = home_side(panel); + vec![Action::Open { panel, anchor: None }, Action::MovePanel { panel, to: Target::NewDock { side } }, Action::ResizeColumn { panel, width }] +} + /// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, -/// group or collapse it, or restore a palette over it. +/// group, collapse or dock it, or restore a palette over it. pub fn validate(workspace: &Workspace) -> Result<(), String> { workspace.validate().map_err(|error| error.to_string())?; if !workspace.layout.contains(&Panel::Canvas) { @@ -124,11 +211,17 @@ pub fn validate(workspace: &Workspace) -> Result<(), String> { Ok(()) } -/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place -/// for), keeping the drawing view's rules. +/// Show a panel and select it at its recorded place, keeping the drawing view's rules. A panel +/// with no recorded place opens in its dock ([`home`]); one whose recorded place is gone joins +/// its docked neighbour's group. pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { let mut next = workspace.clone(); - next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?; + if next.layout.contains(&panel) || next.hidden.iter().any(|(id, _)| *id == panel) { + let fallback = home_anchor(&next.layout, panel).map(|(anchor, _)| anchor); + next.set_visible(panel, true, fallback, permissions).map_err(|error| error.to_string())?; + } else { + next.apply_all(home(&next.layout, panel).into_iter().chain([Action::Activate { panel }]), permissions).map_err(|error| error.to_string())?; + } validate(&next)?; *workspace = next; Ok(()) @@ -188,7 +281,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { set_visible(workspace, Panel::Layers, false)?; set_visible(workspace, Panel::Properties, false)?; } else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) { - // Reopen the last surviving accordion entry before restoring its earlier sibling. + // Reopen the palette that closed last before the one recorded beside it. set_visible(workspace, Panel::Properties, true)?; set_visible(workspace, Panel::Layers, true)?; } @@ -207,6 +300,10 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas); - crate::canvas::show(app, ui); + let mut area = DockArea::new(egui::Id::unique("cadcraft-docking")).input_blocked(native_input_blocked(app, ui.ctx())); + if let Some(panel) = app.raise_panel.take() { + area = area.raise_panel(panel); + } + let mut output = area.show_content(ui, &workspace.layout, &style, &mut Panels { app }); + // Keys typed in a floating panel's own OS window run the main window's shortcuts and + // command line; files dropped on one open like files dropped on the main window. + app.forward_native_input(ui.ctx(), std::mem::take(&mut output.forwarded_events), output.forwarded_modifiers); + #[cfg(not(target_arch = "wasm32"))] + for files in std::mem::take(&mut output.window_files) { + for file in files.dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + app.open_path(&path); } - Panel::ToolSets => crate::palettes::toolsets(app, ui), - Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui), - Panel::Layers => { - egui::ScrollArea::vertical() - .id_salt("dock-layers") - .auto_shrink([false, false]) - .show(ui, |ui| crate::palettes::layers_section(app, ui)); - } - Panel::Properties => { - egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| { - crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) - }); - } - Panel::ExternalRefs => { - egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); - } - }, - ); + } + } // Existing panel close buttons and commands still own their visibility switches. let rendered = workspace; let mut workspace = app.ui.docking.clone(); @@ -290,6 +378,64 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { } } +/// Whether the floating panels' own OS windows ignore input: while a modal dialog is up, since +/// egui's `Modal` blocks only the main window. egui's modal layer is last frame's; the app's +/// state covers the frame a modal opens in. +pub fn native_input_blocked(app: &crate::CadApp, ctx: &egui::Context) -> bool { + app.shows_modal() || ctx.memory(|memory| memory.top_modal_layer()).is_some() +} + +/// The panels' contents, names, size limits and icons for the dock area. +pub struct Panels<'a> { + pub app: &'a mut crate::CadApp, +} + +impl DockContent for Panels<'_> { + fn body(&mut self, ui: &mut egui::Ui, panel: &Panel) { + let app = &mut *self.app; + match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, crate::theme::Tokens::get().canvas); + crate::canvas::show(app, ui); + } + Panel::ToolSets => crate::palettes::toolsets(app, ui), + Panel::QuickCalc => crate::palettes::quickcalc::show(app, ui), + Panel::Layers => { + egui::ScrollArea::vertical() + .id_salt("dock-layers") + .auto_shrink([false, false]) + .show(ui, |ui| crate::palettes::layers_section(app, ui)); + } + Panel::Properties => { + egui::ScrollArea::vertical().id_salt("dock-properties").auto_shrink([false, false]).show(ui, |ui| { + crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) + }); + } + Panel::ExternalRefs => { + egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); + } + } + } + fn logical_label(&self, panel: &Panel) -> String { + crate::i18n::t(panel.label()).into() + } + fn permissions(&self, panel: &Panel) -> Permissions { + permissions(panel) + } + fn limits(&self, panel: &Panel) -> PanelLimits { + panel_limits(panel) + } + fn dock_heights(&self, panel: &Panel) -> Option { + // Layers keeps its list's height above Properties, which takes the rest of the column. + (*panel == Panel::Layers).then(|| DockHeights::new(LAYERS_HEIGHT)) + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, panel: &Panel, _color: egui::Color32) -> bool { + let Some(icon) = panel.icon() else { return false }; + crate::icons::paint(painter, rect, icon, false); + true + } +} + fn panel_limits(panel: &Panel) -> PanelLimits { let (min, max_width) = match panel { Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 000e158f3..017cf5a42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,15 @@ pub struct CadApp { /// Admitted `ui.screenshot` requests, including completed-but-undrained ones. pub(crate) shots: capture::Shots, pub synthetic: Vec, + /// Keys, text and clipboard events a floating panel's own OS window left over, with the + /// modifiers held there, for the main window's next input ([`Self::forward_native_input`]). + forwarded: Vec, + forwarded_modifiers: Option, + /// The main window's own modifiers, put back the frame after forwarded keys went in. + restore_modifiers: Option, + /// A panel opened from a menu or command, whose floating window comes to the front on the + /// next frame (craft-ui's `DockArea::raise_panel`). + pub raise_panel: Option, /// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call. synthetic_injected: bool, /// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or @@ -303,6 +312,10 @@ impl CadApp { capture_identity_epoch: 0, shots: capture::Shots::default(), synthetic: Vec::new(), + forwarded: Vec::new(), + forwarded_modifiers: None, + restore_modifiers: None, + raise_panel: None, synthetic_injected: false, ui_frames: 0, #[cfg(not(target_arch = "wasm32"))] @@ -604,6 +617,9 @@ impl CadApp { if ctx.current_pass_index() != 0 { return; } + if self.restore_modifiers.is_some() { + ctx.request_repaint(); + } // Closing the window with unsaved changes asks first. `run_logic` refreshes viewport // close/focus state, so this must also work while hidden. if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { @@ -625,13 +641,56 @@ impl CadApp { capture::request_hidden_paint(self, ctx); } - /// Input hook: refresh the raw surface snapshot and release at most one synthetic batch. + /// Queue keys typed in a floating panel's own OS window (craft-ui's + /// `DockOutput::forwarded_events` and `forwarded_modifiers`) for the main window's next + /// input. egui gives each OS window only its own input, and the shortcuts, the command line + /// and the canvas all read the main window's, so the keys go in there as if typed in it. + pub fn forward_native_input(&mut self, ctx: &egui::Context, events: Vec, modifiers: Option) { + if events.is_empty() { + return; + } + self.forwarded.extend(events); + if modifiers.is_some() { + self.forwarded_modifiers = modifiers; + } + if self.restore_modifiers.is_none() { + self.restore_modifiers = Some(ctx.input(|i| i.modifiers)); + } + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + + /// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The + /// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the + /// main window). The app's other dialogs are ordinary windows that leave the drawing usable. + pub fn shows_modal(&self) -> bool { + self.close_prompt().is_some() || !self.recovery.items.is_empty() + } + + /// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels' + /// OS windows, and release at most one synthetic batch. /// /// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in /// the buffer is never injected again; the next batch waits for a real `ui` pass to consume /// the previous one. pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { capture::note_raw_geometry(self, ctx, raw); + // The forwarded window's modifiers hold for the frame its keys go in (Shift+Copy is + // COPYBASE); the next frame puts the main window's back, before its own changes. + if !self.forwarded.is_empty() { + // A modifier the main window changed meanwhile is the one to put back. + if let Some(own) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.restore_modifiers = Some(own); + } + if let Some(modifiers) = self.forwarded_modifiers.take() { + raw.events.push(egui::Event::ModifiersChanged(modifiers)); + } + raw.events.append(&mut self.forwarded); + } else if let Some(modifiers) = self.restore_modifiers.take() { + raw.events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } if self.synthetic_injected || self.synthetic.is_empty() { return; } diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index f142ab1f2..51a21834f 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -37,19 +37,10 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option Result { - let panel = Panel::ExternalRefs; - let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel); - let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?; - if first && app.ui.docking.layout.contains(&Panel::Layers) { - let moved = json!({ "operation": "move", "panel": panel.id(), "target": Panel::Layers.id(), "zone": "bottom" }); - if let Ok(r) = crate::docking::command(app, false, &moved) { - return Ok(r); - } - } - Ok(r) + crate::docking::command(app, false, &json!({ "operation": "open", "panel": Panel::ExternalRefs.id() })) } /// A picked file inside the host drawing's folder is attached by its relative path, so the diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 18410ceae..c457052ff 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,7 +1,8 @@ //! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload. use cadcraft_engine::Session; -use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel}; +use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel, menus}; +use craft_ui::docking::{Column, Divider, DockArea, Hit, Layout, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -42,6 +43,110 @@ fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) { harness.run_steps(4); } +fn area() -> egui::Id { + egui::Id::unique("cadcraft-docking") +} + +/// The rect the dock area drew last frame for `hit`. +fn hit(h: &Harness<'_, CadApp>, hit: Hit) -> egui::Rect { + let hits = DockArea::::hit_rects(&h.ctx, area()); + hits.iter().find(|(drawn, _)| *drawn == hit).map(|(_, rect)| *rect).unwrap_or_else(|| panic!("{hit:?} is not drawn: {hits:?}")) +} + +/// The columns of the dock on `side`, from the window edge inwards. +fn columns(layout: &Layout, side: Side) -> Vec> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => return columns.clone(), + _ => {} + } + } + Vec::new() +} + +/// The panels of each group of each column of the dock on `side`. +fn dock_panels(layout: &Layout, side: Side) -> Vec>> { + columns(layout, side).iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect() +} + +/// The tab groups outside the docks: the frames of the split tree. +fn frames(layout: &Layout) -> Vec> { + let mut out = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, .. } => out.push(panels.clone()), + Node::Stack { entries } => out.push(entries.iter().map(|entry| entry.panel).collect()), + _ => {} + } + } + out +} + +#[test] +fn default_layout_is_tool_sets_left_and_the_palettes_right_in_dock_columns() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH, default_workspace, validate}; + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert!(workspace.hidden.is_empty() && workspace.layout.floating.is_empty()); + assert_eq!(frames(&workspace.layout), [[Panel::Canvas]]); + let start = columns(&workspace.layout, Side::Start); + let end = columns(&workspace.layout, Side::End); + assert_eq!(dock_panels(&workspace.layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(dock_panels(&workspace.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!((start[0].width, end[0].width), (TOOL_SETS_WIDTH, PALETTES_WIDTH)); + assert!(start.iter().chain(&end).all(|column| !column.iconic)); + assert!(start.iter().chain(&end).flat_map(|column| &column.groups).all(|group| group.height.is_none() && !group.minimized)); + assert!(!workspace.layout.contains(&Panel::QuickCalc) && !workspace.layout.contains(&Panel::ExternalRefs)); + + // Drawn: Tool Sets left of the drawing, Layers above Properties right of it, Layers at its + // own height and Properties taking the rest of the column. + let h = harness(None, 1280.0, 1.0); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + let tools = hit(&h, Hit::Tab(Panel::ToolSets)); + let layers = hit(&h, Hit::Tab(Panel::Layers)); + let properties = hit(&h, Hit::Tab(Panel::Properties)); + assert!(tools.right() <= drawing.left() + 1.0, "{tools:?} {drawing:?}"); + assert!(layers.left() >= drawing.right() - 1.0 && properties.left() >= drawing.right() - 1.0, "{layers:?} {properties:?} {drawing:?}"); + let seam = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))); + assert!((seam.center().y - layers.bottom() - 280.0).abs() < 8.0, "{layers:?} {seam:?}"); + assert!(properties.top() > seam.top()); +} + +#[test] +fn palette_icons_are_painted_in_code_and_shown_in_an_iconic_column() { + use cadcraft_ui_egui::docking::Panels; + let mut app = CadApp::new(Session::default(), Services::default()); + let ctx = egui::Context::default(); + let mut paint = |panel: Panel| { + let mut painted = false; + let mut content = Panels { app: &mut app }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel, egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + for panel in [Panel::ToolSets, Panel::QuickCalc, Panel::Layers, Panel::Properties, Panel::ExternalRefs] { + let (painted, shapes) = paint(panel); + assert!(painted && shapes > 0, "{panel:?}: {painted} {shapes}"); + } + assert_eq!(paint(Panel::Canvas), (false, 0)); + + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"action":{"SetIconic":{"panel":"layers","iconic":true}}})).unwrap(); + h.run_steps(4); + let drawing = h.state().canvas.rect.unwrap(); + let layers = hit(&h, Hit::Icon(Panel::Layers)); + let properties = hit(&h, Hit::Icon(Panel::Properties)); + assert!(layers.left() >= drawing.right() - 1.0 && layers.top() < properties.top(), "{layers:?} {properties:?}"); +} + #[test] fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { let mut harness = harness(None, 1280.0, 1.0); @@ -94,9 +199,11 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}), json!({"operation":"spin", "panel":"layers"}), json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}), + json!({"action":{"MovePanel":{"panel":"canvas","to":{"NewDock":{"side":"Start"}}}}}), + json!({"action":{"Move":{"panel":"layers","anchor":"canvas","placement":{"Tab":{"before":null}}}}}), json!(null), ] { - assert!(harness.state_mut().run("ui.dock", params).is_err()); + assert!(harness.state_mut().run("ui.dock", params.clone()).is_err(), "{params}"); assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); assert_eq!(harness.state().session.doc().unwrap(), &drawing); } @@ -131,48 +238,39 @@ fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() { } #[test] -fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() { - use craft_ui::{docking::Action, layout::SplitSize}; +fn automation_reaches_column_and_group_resizes_tab_reorder_and_minimize_through_the_same_handler() { + use craft_ui::docking::Action; let mut h = harness(None, 1280.0, 1.0); - h.state_mut() - .run( - "ui.dock", - json!({"action":serde_json::to_value(Action::::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}), - ) - .unwrap(); - h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap(); - h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap(); - assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0)))); + let act = |h: &mut Harness<'_, CadApp>, action: Action| { + h.state_mut().run("ui.dock", json!({"action": serde_json::to_value(action).unwrap()})).unwrap(); + }; + act(&mut h, Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }); + act(&mut h, Action::ResizeGroup { panel: Panel::Layers, height: Some(160.0) }); + let layout = &h.state().ui.docking.layout; + assert_eq!(columns(layout, Side::Start)[0].width, 280.0); + assert_eq!(columns(layout, Side::End)[0].groups[0].height, Some(160.0)); h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap(); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [[[Panel::Properties, Panel::Layers]]]); + act(&mut h, Action::SetMinimized { panel: Panel::Layers, minimized: true }); h.run_steps(4); - let panels = h.state().ui.docking.layout.panels(); - assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap()); - assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0))); + assert!(columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].minimized); + assert_eq!(columns(&h.state().ui.docking.layout, Side::Start)[0].width, 280.0); } #[test] -fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { +fn group_divider_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { let mut h = harness(None, 1280.0, 1.0); let drawing = h.state().session.doc().unwrap().clone(); - let center = h - .get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") - .into_iter() - .find(|node| node.rect().width() > node.rect().height()) - .unwrap() - .rect() - .center(); + let layers_height = |h: &Harness<'_, CadApp>| columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].height; + let center = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))).center(); drag(&mut h, center, center + vec2(0.0, -45.0)); - let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); - h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") - .into_iter() - .find(|node| node.rect().width() > node.rect().height()) - .unwrap() - .focus(); + let height = layers_height(&h).expect("the drag sets the Layers group's height"); + h.ctx.memory_mut(|memory| memory.request_focus(DockArea::::hit_id(area(), &Hit::Divider(Divider::Group(Panel::Layers))))); h.run_steps(2); h.key_press(egui::Key::ArrowDown); h.run_steps(4); - let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); - assert!(after > height, "keyboard resize must reach the same panel command handler"); + let after = layers_height(&h).unwrap(); + assert!(after > height, "keyboard resize must reach the same panel command handler: {height} {after}"); assert_eq!(h.state().session.doc().unwrap(), &drawing); } @@ -219,6 +317,67 @@ fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}"); } +#[test] +fn palettes_off_and_on_restore_the_right_dock_column() { + let mut h = harness(None, 1280.0, 1.0); + let before = h.state().ui.docking.layout.clone(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::Layers) && !h.state().ui.docking.layout.contains(&Panel::Properties)); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), dock_panels(&before, Side::End)); + assert_eq!(columns(layout, Side::End)[0].width, columns(&before, Side::End)[0].width); +} + +#[test] +fn window_menu_reopens_closed_and_new_palettes_in_their_dock_groups() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH}; + let mut h = harness(None, 1280.0, 1.0); + // A closed palette comes back where it was. + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"layers"})).unwrap(); + h.run_steps(3); + menus::activate(h.state_mut(), "ui.panel.layers"); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + // Tool Sets turned off and on in the Window menu comes back in the left dock at its width. + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::ToolSets)); + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // After Reset nothing has a recorded place: External References opens below Properties. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties], vec![Panel::ExternalRefs]]]); + // Tool Sets with no recorded place and no left dock makes one at its width. + let mut workspace = cadcraft_ui_egui::docking::default_workspace(); + workspace.apply(craft_ui::docking::Action::Close { panel: Panel::ToolSets }, cadcraft_ui_egui::docking::permissions).unwrap(); + workspace.hidden.clear(); + h.state_mut().ui.docking = workspace; + h.state_mut().ui.show_toolsets = false; + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // With the palettes off, External References makes a new right dock at the palettes' width. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), [[[Panel::ExternalRefs]]]); + assert_eq!(columns(layout, Side::End)[0].width, PALETTES_WIDTH); + assert_eq!(frames(layout), [[Panel::Canvas]]); +} + #[test] fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { let mut harness = harness(None, 1000.0, 1.0); @@ -276,15 +435,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac let drawing = h.state().session.doc().unwrap().clone(); let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); type_command(&mut h, "PROPERTIES"); - assert!(h.state().ui.docking.layout.contains(&Panel::Properties)); - assert!(h.state().ui.docking.layout.contains(&Panel::Layers)); - let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); - assert_eq!(restored_tools.anchor, tools.anchor); - assert_eq!(restored_tools.placement, tools.placement); - assert_eq!(restored_tools.size, tools.size); - assert_eq!(restored_tools.floating, tools.floating); - assert_eq!(restored_tools.stack, tools.stack); - assert!(restored_tools.sibling.contains(&Panel::Properties)); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(), tools); h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); @@ -310,34 +462,27 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { } #[test] -fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { +fn canvas_reset_during_column_drag_discards_old_resize_and_ends_gesture() { use craft_ui::docking::Action; - use craft_ui::layout::SplitSize; let mut h = harness(None, 1280.0, 1.0); - // Give the old root divider a size different from Reset's 220px ToolSets pane. + // Give the Tool Sets column a width different from Reset's. h.state_mut() - .run( - "ui.dock", - json!({"action":Action::::ResizeSplit { - path: Vec::new(), size: SplitSize::FixedFirst(280.0), - }}), - ) + .run("ui.dock", json!({"action": serde_json::to_value(Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }).unwrap()})) .unwrap(); h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); - let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::::new())); - let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center(); + let divider = Hit::Divider(Divider::Column(Panel::ToolSets)); + let handle = DockArea::::hit_id(area(), ÷r); + let from = hit(&h, divider).center(); h.event(Event::PointerMoved(from)); h.run_steps(1); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.run_steps(1); - // Canvas is visited before the ancestor ResizeHandle. The command runs before - // that ancestor queues this frame's ResizeSplit against the old immutable tree. - assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer"); + assert_eq!(h.ctx.dragged_id(), Some(handle), "the column's divider owns the pointer"); assert!(h.ctx.input(|input| input.pointer.primary_down())); - // A ResizeHandle takes keyboard focus on press. Release only that keyboard focus - // so the real Canvas command line can submit while the pointer remains captured. - h.ctx.memory_mut(|memory| memory.surrender_focus(splitter)); + // A divider takes keyboard focus on press. Release only that keyboard focus so the real + // Canvas command line can submit while the pointer remains captured. + h.ctx.memory_mut(|memory| memory.surrender_focus(handle)); assert!(!h.ctx.egui_wants_keyboard_input()); let to = from + vec2(36.0, 0.0); h.event(Event::PointerMoved(to)); @@ -346,10 +491,10 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { h.run_steps(1); let expected = cadcraft_ui_egui::docking::default_workspace(); assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command"); - assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree"); + assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's layout"); assert_eq!(h.state().ui.docking.hidden, expected.hidden); - // Keep the physical button down: replacement must terminate the old divider gesture, - // rather than letting its stable root ID continue resizing the new workspace. + // Keep the physical button down: replacement must end the old divider gesture rather than + // letting it go on resizing the new workspace. let later = to + vec2(24.0, 0.0); h.event(Event::PointerMoved(later)); h.run_steps(2); @@ -386,35 +531,65 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { } #[test] -fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { - use craft_ui::docking::{DockArea, Hit, Node}; +fn palettes_dock_only_in_the_left_and_right_columns() { + let mut h = harness(None, 1280.0, 1.0); + let document = h.state().session.doc().unwrap().clone(); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + // Neither the drawing nor the bottom edge takes a drop: the palette floats there. + for to in [drawing.center(), egui::pos2(drawing.center().x, drawing.bottom() - 4.0)] { + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, to); + let layout = &h.state().ui.docking.layout; + assert_eq!(frames(layout), [[Panel::Canvas]], "{to:?}: {:?}", layout.root); + assert_eq!(dock_panels(layout, Side::End), [[[Panel::Properties]]], "{to:?}"); + assert!(layout.floating.iter().any(|window| window.contains(&Panel::Layers)), "{to:?}: {layout:?}"); + } + // The left window edge, where Tool Sets docks, takes the palette as a new outer column. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let column = hit(&h, Hit::ColumnBar(Panel::ToolSets)); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(column.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]], [[Panel::ToolSets]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + // Without a left dock, the left window edge makes one. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.toolsets", json!({"on": false})).unwrap(); + h.run_steps(4); + assert!(columns(&h.state().ui.docking.layout, Side::Start).iter().all(|column| column.groups.is_empty())); + let drawing = h.state().canvas.rect.unwrap(); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(drawing.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + assert_eq!(h.state().session.doc().unwrap(), &document); +} + +#[test] +fn group_drag_bar_moves_the_whole_group_into_the_other_dock() { let mut h = harness(None, 1280.0, 1.0); h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); h.run_steps(4); - let bar = |h: &Harness<'_, CadApp>| { - DockArea::::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking")) - .into_iter() - .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) - .map(|(_, rect)| rect) - }; - let from = bar(&h).expect("the Tool Sets group draws a drag bar").center(); - let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); - let to = egui::pos2(drawing.right() - 10.0, drawing.center().y); + let bar = DockArea::::hit_rects(&h.ctx, area()) + .into_iter() + .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) + .map(|(_, rect)| rect) + .expect("the Tool Sets group draws a drag bar"); + // Into the middle of the Properties group, as tabs. + let properties = hit(&h, Hit::Tab(Panel::Properties)); + let column = hit(&h, Hit::ColumnBar(Panel::Properties)); + let to = egui::pos2(column.center().x, properties.bottom() + 120.0); let document = h.state().session.doc().unwrap().clone(); - drag(&mut h, from, to); + drag(&mut h, bar.center(), to); let layout = &h.state().ui.docking.layout; - assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group"); - // The drawing's frame splits: the drawing on the left, the whole group on the right. - let mut nodes: Vec<&Node> = layout.root.iter().collect(); - let mut beside = false; - while let Some(node) = nodes.pop() { - if let Node::Split { first, second, .. } = node { - beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. }) - if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]); - nodes.push(first); - nodes.push(second); - } - } - assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root); + assert!(layout.floating.is_empty(), "{layout:?}"); + let end = dock_panels(layout, Side::End); + assert_eq!(end.len(), 1, "{end:?}"); + assert!(end[0].iter().any(|group| group.contains(&Panel::ToolSets) && group.contains(&Panel::Layers)), "{end:?}"); + assert_eq!(frames(layout), [[Panel::Canvas]]); assert_eq!(h.state().session.doc().unwrap(), &document); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index c908bc9e2..92554f7dd 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -18,6 +18,7 @@ mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; mod native_menu_text_keys; +mod native_windows; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/native_windows.rs b/crates/ui-egui/tests/it/native_windows.rs new file mode 100644 index 000000000..e98825a80 --- /dev/null +++ b/crates/ui-egui/tests/it/native_windows.rs @@ -0,0 +1,116 @@ +//! Floating panels in their own OS windows: a modal dialog blocks their input, and keys typed in +//! one run the main window's shortcuts and command line. + +use cadcraft_engine::Session; +use cadcraft_engine::autosave::Recoverable; +use cadcraft_ui_egui::closing::Choice; +use cadcraft_ui_egui::docking::native_input_blocked; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +fn frame(app: &mut CadApp, ctx: &egui::Context) { + frame_with(app, ctx, Vec::new()); +} + +/// A frame whose main-window input carries `events`. +fn frame_with(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() }; + app.raw_input_hook(ctx, &mut raw); + cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); +} + +fn pump(app: &mut CadApp, ctx: &egui::Context) { + for _ in 0..3 { + frame(app, ctx); + } +} + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn running(app: &CadApp) -> Option<&str> { + app.session.running.as_ref().map(|command| command.id.as_str()) +} + +#[test] +fn modal_dialogs_block_native_panel_windows() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // "Save changes?" while a dirty drawing closes. + app.run("circle", json!({"center": [0, 0], "radius": 1})).unwrap(); + app.request_close(None); + assert!(native_input_blocked(&app, &ctx), "close prompt"); + pump(&mut app, &ctx); + assert!(native_input_blocked(&app, &ctx), "close prompt shown"); + app.answer_close(Choice::Cancel); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx), "after Cancel"); + + // Drawing Recovery. + app.recovery.items.push(Recoverable { path: "missing.sv$".into(), drawing: "Plan.dxf".into(), modified: 0, bytes: 0 }); + assert!(native_input_blocked(&app, &ctx), "drawing recovery"); + app.recovery.items.clear(); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // Any other egui modal, from the frame after it is shown. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Modal::new(egui::Id::unique("test_modal")).show(ui.ctx(), |ui| ui.label("modal")); + }); + out.textures_delta.clear(); + assert!(native_input_blocked(&app, &ctx), "egui modal"); +} + +#[test] +fn keys_forwarded_from_a_native_window_reach_the_command_line_and_shortcuts() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + + let typed = vec![Event::Text("LINE".into()), key(Key::Enter, true, Modifiers::NONE), key(Key::Enter, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, typed, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("line")); + assert_eq!(app.cmd.history, ["LINE"]); + + let escape = vec![key(Key::Escape, true, Modifiers::NONE), key(Key::Escape, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, escape, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), None); + + let ortho = app.session.settings.orthomode; + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::NONE), key(Key::F8, false, Modifiers::NONE)], Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(app.session.settings.orthomode, !ortho, "F8"); + + // Shift held in the native window: Cmd/Ctrl+Shift+C arrives as Copy and runs COPYBASE. The + // main window's own modifiers come back afterwards. + app.forward_native_input(&ctx, vec![Event::Copy], Some(Modifiers::SHIFT | Modifiers::COMMAND)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("copybase")); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE); +} + +#[test] +fn a_main_window_modifier_change_outlasts_keys_forwarded_meanwhile() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::COMMAND), key(Key::F8, false, Modifiers::COMMAND)], Some(Modifiers::COMMAND)); + // Shift is released in the main window in the frame the forwarded keys go in. + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE)]); + pump(&mut app, &ctx); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE, "the released Shift is not put back"); +}