diff --git a/ROADMAP.md b/ROADMAP.md index 19226dd4..c88aecc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Entry | |---|---| | 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) | +| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) | | 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. | | 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. | | 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | @@ -133,6 +134,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) | +| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) | | 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number | | 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers | diff --git a/crates/engine/src/cmd/hatch/edit.rs b/crates/engine/src/cmd/hatch/edit.rs index ae7a9551..eeeb2dbd 100644 --- a/crates/engine/src/cmd/hatch/edit.rs +++ b/crates/engine/src/cmd/hatch/edit.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use super::assoc::{detach, same_shape}; use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style}; use crate::cmd::helpers::lwpoly; -use crate::cmd::{bad, bool_or, point_param, str_param, targets}; +use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets}; use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step}; /// Upper bound on hatches edited by one call. @@ -248,7 +248,19 @@ fn gradient_edit(p: &Value) -> Result> { Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered })) } +/// An explicit `scale` must be positive and an explicit `angle` a number, checked before anything +/// is edited so a refused request changes nothing (absent and null leave them as they are). +fn check_numbers(p: &Value) -> Result<()> { + size_param("hatchedit", p, "scale", false)?; + match p.get("angle") { + None | Some(Value::Null) => Ok(()), + Some(v) if v.as_f64().is_some_and(f64::is_finite) => Ok(()), + Some(_) => Err(bad("hatchedit", "`angle` must be a number of degrees")), + } +} + pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result { + check_numbers(p)?; let hs = hatches(s, p)?; let style = match str_param(p, "style") { Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?), diff --git a/crates/engine/tests/hatch_numeric_validation.rs b/crates/engine/tests/hatch_numeric_validation.rs new file mode 100644 index 00000000..1173db49 --- /dev/null +++ b/crates/engine/tests/hatch_numeric_validation.rs @@ -0,0 +1,86 @@ +//! HATCHEDIT refuses an explicit bad `scale` or `angle` before changing anything: no partial +//! edit, no undo step, redo history kept. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::{Value, json}; + +fn fixture() -> (Session, String) { + let mut s = Session::new(); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap(); + let handle = s.execute("hatch", &json!({"points": [[5, 5]]})).unwrap()["handle"].as_str().unwrap().to_owned(); + // One redo entry, which a rejected edit must leave alone. + s.execute("circle", &json!({"center": [20, 20], "radius": 1})).unwrap(); + s.undo().unwrap(); + assert_eq!(s.state().unwrap().redo.len(), 1); + (s, handle) +} + +fn hatch_props(s: &Session) -> (f64, f64) { + let hatches: Vec<(f64, f64)> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some((h.scale, h.angle.to_degrees())) } else { None }) + .collect(); + assert_eq!(hatches.len(), 1); + hatches[0] +} + +fn assert_props(s: &Session, scale: f64, angle: f64) { + let (sc, an) = hatch_props(s); + assert!((sc - scale).abs() < 1e-9 && (an - angle).abs() < 1e-9, "scale {sc}, angle {an}"); +} + +fn edit(s: &mut Session, handle: &str, extra: Value) -> cadcraft_engine::Result { + let mut params = json!({ "handles": [handle] }); + for (k, v) in extra.as_object().unwrap() { + params[k] = v.clone(); + } + s.execute("hatchedit", ¶ms) +} + +#[test] +fn bad_scale_or_angle_is_refused_and_changes_nothing() { + let cases = [ + (json!({"scale": 0}), "scale"), + (json!({"scale": -1}), "scale"), + (json!({"scale": "x"}), "scale"), + (json!({"angle": "x"}), "angle"), + (json!({"scale": 0, "angle": 45}), "scale"), + (json!({"scale": 2, "angle": "x"}), "angle"), + (json!({"scale": 0, "recreate": "polyline"}), "scale"), + (json!({"angle": "x", "recreate": "polyline"}), "angle"), + ]; + for (extra, key) in cases { + let (mut s, handle) = fixture(); + let before = s.doc().unwrap().clone(); + let (undo, redo, revision) = { + let st = s.state().unwrap(); + (st.undo.len(), st.redo.len(), st.revision) + }; + let err = edit(&mut s, &handle, extra.clone()).expect_err(&format!("{extra} should be refused")); + assert!(err.to_string().contains(&format!("`{key}`")), "{extra}: {err}"); + assert_eq!(s.doc().unwrap(), &before, "{extra}: the drawing is untouched"); + let st = s.state().unwrap(); + assert_eq!((st.undo.len(), st.redo.len(), st.revision), (undo, redo, revision), "{extra}: history is untouched"); + } +} + +#[test] +fn valid_omitted_and_null_values_still_edit() { + let (mut s, handle) = fixture(); + edit(&mut s, &handle, json!({"scale": 2, "angle": 45})).unwrap(); + assert_props(&s, 2.0, 45.0); + // Omitted or null numeric fields keep what the hatch has while another property changes. + edit(&mut s, &handle, json!({"style": "outer"})).unwrap(); + assert_props(&s, 2.0, 45.0); + edit(&mut s, &handle, json!({"scale": null, "angle": null, "style": "normal"})).unwrap(); + assert_props(&s, 2.0, 45.0); + // Zero and negative angles are angles. + edit(&mut s, &handle, json!({"angle": 0})).unwrap(); + assert_props(&s, 2.0, 0.0); + edit(&mut s, &handle, json!({"angle": -30})).unwrap(); + assert_props(&s, 2.0, -30.0); +} diff --git a/docs/gaps.md b/docs/gaps.md index 24b1ee54..a69dc67d 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-12 · **Change:** minor (property-based and hostile-input tests for geometry landed; S6 removed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (F13: HATCHEDIT refuses a bad scale or angle before editing (#724); previously minor: SCRIPT command reading `.scr` files landed; E2 removed) · **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 @@ -36,6 +36,7 @@ do them first. | F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 | | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 | +| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); no hatch dialog (HATCH and HATCHEDIT options work at the command line and in JSON; HATCHEDIT refuses a bad `scale` or `angle` before editing, #724), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 | | F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3). The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, and plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | @@ -149,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed | | 2026-10-13 | minor | STRETCH takes crossing and window polygons (CP, WP), BOX and a fence (F), typed and as JSON (`polygon`, `fence`): F1 narrowed | | 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (deterministic xorshift generator, no new dependency; known answers at 1e6–1e7 coordinates): S6 removed | +| 2026-10-12 | trivial | F13: HATCHEDIT refuses a bad scale or angle before editing (#724) | | 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed | | 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes | | 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |