From d738ebfcc65aff74a9f88426f6c9e5cb06fec9ba Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 03:19:52 +0300 Subject: [PATCH] Standardize parametric constraints and interoperability --- Cargo.lock | 6 +- Cargo.toml | 6 +- benches/performance_benchmarks.rs | 173 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- docs/plugin-template-v2/Cargo.toml | 2 +- src/app/automation.rs | 85 +- src/app/command_driver.rs | 2130 +++++++++------ src/app/commands/draw.rs | 481 ++-- src/app/document.rs | 124 +- src/app/history.rs | 129 +- src/app/mod.rs | 19 +- src/app/properties.rs | 667 ++--- src/app/settings.rs | 5 - src/app/update/file.rs | 2011 +++++++------- src/app/update/mod.rs | 1022 +++---- src/app/update/viewport.rs | 830 +++--- src/app/view/mod.rs | 2355 ++++++++--------- src/app/view/modal.rs | 1 - src/command.rs | 180 +- src/modules/parametric/coincident.rs | 2 +- src/modules/parametric/point_on_entity.rs | 4 +- src/modules/parametric/tools.rs | 14 +- src/modules/parametric/value.rs | 271 +- src/scene/dimension_assoc.rs | 256 +- src/scene/dwg_native_constraints.rs | 2342 ++++++++++++---- src/scene/entity.rs | 429 ++- src/scene/mod.rs | 1248 ++++----- src/scene/model/object.rs | 19 +- src/scene/modify.rs | 158 +- src/scene/named_parameters.rs | 27 +- src/scene/named_parameters_persist.rs | 210 -- ...nstraints.rs => parametric_constraints.rs} | 609 +++-- .../{sketch_solve.rs => parametric_solve.rs} | 955 ++++++- src/scene/sketch_persist.rs | 393 --- src/ui/statusbar/mod.rs | 191 +- src/ui/window/named_parameters.rs | 10 +- src/ui/window/options.rs | 17 - ...lve.rs => parametric_constraints_solve.rs} | 337 +-- tests/sketch_constraints_xrecord_roundtrip.rs | 159 -- 41 files changed, 10038 insertions(+), 7847 deletions(-) delete mode 100644 src/scene/named_parameters_persist.rs rename src/scene/{sketch_constraints.rs => parametric_constraints.rs} (63%) rename src/scene/{sketch_solve.rs => parametric_solve.rs} (69%) delete mode 100644 src/scene/sketch_persist.rs rename tests/{sketch_constraints_solve.rs => parametric_constraints_solve.rs} (85%) delete mode 100644 tests/sketch_constraints_xrecord_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 5d95a48f7..7efbae01f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7709884f301cfe292f58a002a514939932be67d0#7709884f301cfe292f58a002a514939932be67d0" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" dependencies = [ "ahash 0.8.12", "anyhow", @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b#2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 4a83d5bcd..688a7c795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,9 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2db3ac9aa8ce5e75b53f21fefd4b40ca1afaff8b" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index edde1bb36..4ae2dcf9c 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -48,16 +48,20 @@ use acadrust::{CadDocument, DxfReader, DxfWriter, EntityType}; use glam::{DVec3, Mat4, Vec3}; use iced::{Color, Point, Rectangle}; +use OpenCADStudio::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, +}; use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit}; use OpenCADStudio::scene::pick::interaction_index::InteractionIndex; use OpenCADStudio::scene::pick::selection_state::SelectionState; use OpenCADStudio::scene::pipeline::wire_arena::partition_wires; -use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; use OpenCADStudio::scene::view::camera::Camera; use OpenCADStudio::scene::{ChangeKind, Scene}; use OpenCADStudio::snap::Snapper; use OpenCADStudio::ui::icons::{self, CHECK}; -use OpenCADStudio::ui::overlay::{grid_segments, GridCanvasState, GridKey, GridParams, GridStyle, should_reuse}; +use OpenCADStudio::ui::overlay::{ + grid_segments, should_reuse, GridCanvasState, GridKey, GridParams, GridStyle, +}; use OpenCADStudio::ui::properties::LinetypeItem; use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; @@ -109,8 +113,8 @@ impl Default for BenchmarkRunner { impl BenchmarkRunner { pub fn new() -> Self { let args: Vec = env::args().collect(); - let quick_mode = args.iter().any(|a| a == "--quick" || a == "-q") - || env::var("CAD_BENCH_QUICK").is_ok(); + let quick_mode = + args.iter().any(|a| a == "--quick" || a == "-q") || env::var("CAD_BENCH_QUICK").is_ok(); let filter = args .windows(2) @@ -220,22 +224,37 @@ impl BenchmarkRunner { /// Print summary table to stdout and persist JSON report. pub fn finish(&self) { - let baseline_map: HashMap = if let Some(ref path) = self.baseline_path { - if let Ok(content) = std::fs::read_to_string(path) { - if let Ok(report) = serde_json::from_str::(&content) { - println!("\nLoaded baseline from '{}' ({} metrics)", path.display(), report.metrics.len()); - report.metrics.into_iter().map(|m| (m.name.clone(), m)).collect() + let baseline_map: HashMap = + if let Some(ref path) = self.baseline_path { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(report) = serde_json::from_str::(&content) { + println!( + "\nLoaded baseline from '{}' ({} metrics)", + path.display(), + report.metrics.len() + ); + report + .metrics + .into_iter() + .map(|m| (m.name.clone(), m)) + .collect() + } else { + eprintln!( + "Warning: Failed to parse baseline JSON at '{}'", + path.display() + ); + HashMap::new() + } } else { - eprintln!("Warning: Failed to parse baseline JSON at '{}'", path.display()); + eprintln!( + "Warning: Could not read baseline file at '{}'", + path.display() + ); HashMap::new() } } else { - eprintln!("Warning: Could not read baseline file at '{}'", path.display()); HashMap::new() - } - } else { - HashMap::new() - }; + }; println!("\n========================================================================================================================"); println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT "); @@ -301,7 +320,11 @@ impl BenchmarkRunner { timestamp: chrono_now(), os: env::consts::OS.to_string(), arch: env::consts::ARCH.to_string(), - profile: if cfg!(debug_assertions) { "debug".to_string() } else { "release".to_string() }, + profile: if cfg!(debug_assertions) { + "debug".to_string() + } else { + "release".to_string() + }, is_quick_mode: self.quick_mode, metrics: self.results.clone(), }; @@ -311,9 +334,16 @@ impl BenchmarkRunner { let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&self.output_path, json) { - eprintln!("Error saving metrics to '{}': {}", self.output_path.display(), e); + eprintln!( + "Error saving metrics to '{}': {}", + self.output_path.display(), + e + ); } else { - println!("Saved complete performance metrics JSON to: {}\n", self.output_path.display()); + println!( + "Saved complete performance metrics JSON to: {}\n", + self.output_path.display() + ); } } } @@ -381,9 +411,27 @@ fn bench_scene_entity_ingestion(runner: &mut BenchmarkRunner) { let y = (i / 50) as f64 * 50.0 + 6000.0; let mut pl = LwPolyline::new(); pl.vertices = vec![ - LwVertex { location: Vector2::new(x, y), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, - LwVertex { location: Vector2::new(x + 20.0, y), bulge: 0.5, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, - LwVertex { location: Vector2::new(x + 20.0, y + 20.0), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 2 }, + LwVertex { + location: Vector2::new(x, y), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 20.0, y), + bulge: 0.5, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + LwVertex { + location: Vector2::new(x + 20.0, y + 20.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 2, + }, ]; scene.add_entity(EntityType::LwPolyline(pl)); } @@ -462,8 +510,20 @@ fn bench_analytical_tessellation_zoom(runner: &mut BenchmarkRunner) { let mut pline = LwPolyline::new(); pline.is_closed = true; pline.vertices = vec![ - LwVertex { location: Vector2::new(x, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, - LwVertex { location: Vector2::new(x + 30.0, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, + LwVertex { + location: Vector2::new(x, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 30.0, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, ]; scene.add_entity(EntityType::LwPolyline(pline)); } @@ -610,7 +670,12 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { let view_rot = Mat4::IDENTITY; let eye = DVec3::new(1000.0, 1000.0, 2000.0); - let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let mut click_samples = Vec::with_capacity(5); @@ -620,7 +685,15 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { for q in 0..n_queries { let cx = ((q * 37) % 1920) as f32; let cy = ((q * 53) % 1080) as f32; - let hit = click_hit(Point::new(cx, cy), wires.as_slice(), view_rot, eye, bounds, false, 8.0); + let hit = click_hit( + Point::new(cx, cy), + wires.as_slice(), + view_rot, + eye, + bounds, + false, + 8.0, + ); black_box(hit); } let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); @@ -716,7 +789,12 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) { let view_rot = Mat4::IDENTITY; let eye = DVec3::new(500.0, 500.0, 1000.0); - let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; let grid_origin = Vec3::ZERO; let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); @@ -729,10 +807,7 @@ fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) { let wx = (q as f64 * 7.3) % 2500.0; let wy = (q as f64 * 11.7) % 2500.0; let cursor_world = DVec3::new(wx, wy, 0.0); - let cursor_screen = Point::new( - ((q * 23) % 1920) as f32, - ((q * 31) % 1080) as f32, - ); + let cursor_screen = Point::new(((q * 23) % 1920) as f32, ((q * 31) % 1080) as f32); let hit = snapper.snap( cursor_world, @@ -798,11 +873,23 @@ fn bench_geometric_constraint_solving(runner: &mut BenchmarkRunner) { lines.push((h1, h2, h3)); } - let cs = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let cs = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); for &(h1, h2, h3) in &lines { - cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h1), SketchRef::whole(h2)], None); - cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h2), SketchRef::whole(h3)], None); - cs.add(ConstraintKind::Parallel, vec![SketchRef::whole(h1), SketchRef::whole(h3)], None); + cs.add( + ConstraintKind::Perpendicular, + vec![ParametricRef::whole(h1), ParametricRef::whole(h2)], + None, + ); + cs.add( + ConstraintKind::Perpendicular, + vec![ParametricRef::whole(h2), ParametricRef::whole(h3)], + None, + ); + cs.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(h1), ParametricRef::whole(h3)], + None, + ); } let target = lines[run_idx % lines.len()].0; @@ -858,7 +945,9 @@ fn bench_dxf_io_throughput(runner: &mut BenchmarkRunner) { for _ in 0..runs { let t0 = Instant::now(); - let bytes = DxfWriter::new(&doc).write_to_vec().expect("DXF write succeeds"); + let bytes = DxfWriter::new(&doc) + .write_to_vec() + .expect("DXF write succeeds"); write_samples.push(t0.elapsed().as_secs_f64() * 1000.0); dxf_bytes = bytes; } @@ -1095,8 +1184,12 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { // ── 1. Uncached grid geometry generation ── if runner.should_run("ui_grid_geometry_uncached") { for _ in 0..10 { - let _ = black_box(grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1)); - let _ = black_box(grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2)); + let _ = black_box(grid_segments( + view_rot1, eye1, bounds1, step1, origin1, axes1, limits1, + )); + let _ = black_box(grid_segments( + view_rot2, eye2, bounds2, step2, origin2, axes2, limits2, + )); } let n = if runner.quick_mode { 20 } else { 100 }; @@ -1172,7 +1265,11 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { let t0 = Instant::now(); let mut hit_count = 0u32; for _ in 0..n { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); + let key = GridKey::from_grids( + black_box(&grids), + black_box(canvas_bounds), + GridStyle::default(), + ); if should_reuse(state.key.borrow().as_ref(), &key) { hit_count += 1; } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 854dc014b..fb55f5254 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 0bb5e1460..9aa9b8bfc 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index bcb242dce..d9a7576f1 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 300cf870a..23a662f1f 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7709884f301cfe292f58a002a514939932be67d0" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" } diff --git a/src/app/automation.rs b/src/app/automation.rs index 7089fead6..3b53ce982 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -18,7 +18,7 @@ use std::io::{BufRead, Write}; use std::path::PathBuf; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use super::OpenCADStudio; @@ -318,7 +318,9 @@ impl OpenCADStudio { self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; self.tabs[i].scene.load_named_parameters_from_document(); - self.tabs[i].scene.load_sketch_constraints_from_document(); + self.tabs[i] + .scene + .load_parametric_constraints_from_document(); self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, @@ -1399,7 +1401,8 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let stale = acadrust::Handle::from(9999); app.tabs[app.active_tab].scene.solid_models.insert( - stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + stale, + cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), ); let path = std::env::temp_dir().join(format!( "ocs_automation_finalize_test_{}.dxf", @@ -1415,7 +1418,8 @@ mod tests { let mut corrupt = acadrust::entities::Circle::new(); corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0); corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle - doc.add_entity(acadrust::EntityType::Circle(corrupt)).unwrap(); + doc.add_entity(acadrust::EntityType::Circle(corrupt)) + .unwrap(); let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version) .expect("save a document containing a corrupt entity"); std::fs::write(&path, bytes).unwrap(); @@ -1423,19 +1427,29 @@ mod tests { let p = path.to_string_lossy().replace('\\', "\\\\"); let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#)); assert_eq!(result["ok"], true, "{}", result["error"]); - assert_eq!(result["total"], 1, "the corrupt circle must not survive the open"); - assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics"); + assert_eq!( + result["total"], 1, + "the corrupt circle must not survive the open" + ); + assert_eq!( + result["purged"], 1, + "the purge count must be reported, matching the UI open path's diagnostics" + ); let i = app.active_tab; assert!(!app.tabs[i].scene.solid_models.contains_key(&stale)); - assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent()); + assert_eq!( + app.tabs[i].scene.material_base_dir.as_deref(), + path.parent() + ); assert!( app.tabs[i].scene.document.source_path.is_some(), "automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)" ); app.tabs[i].scene.solid_models.insert( - stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + stale, + cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), ); assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); assert!(app.tabs[i].scene.solid_models.is_empty()); @@ -1523,26 +1537,41 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Type M2P let _ = app.update(Message::CommandInput("M2P".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("MTP") + ); assert!(app.tabs[0].suspended_cmd.is_some()); - assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Point 1: 0,0 let _ = app.update(Message::CommandInput("0,0".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("MTP") + ); // Point 2: 10,20 let _ = app.update(Message::CommandInput("10,20".to_string())); let _ = app.update(Message::CommandSubmit); // MTP should have finished and restored LINE, with midpoint (5, 10, 0) - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); assert!(app.tabs[0].suspended_cmd.is_none()); assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); @@ -1561,7 +1590,10 @@ mod tests { let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::SnapOverrideMtp); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("MTP") + ); assert_eq!( app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE") @@ -1577,17 +1609,26 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Type MTP let _ = app.update(Message::CommandInput("MTP".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("MTP") + ); // Escape during MTP let _ = app.update(Message::CommandEscape); // Parent LINE must be restored, not cancelled! - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); assert!(app.tabs[0].suspended_cmd.is_none()); // Escape again cancels LINE @@ -1605,7 +1646,10 @@ mod tests { // Start LINE let _ = app.update(Message::CommandInput("LINE".to_string())); let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); // Simulate typing 'm', '2', 'p' character by character let _ = app.update(Message::CommandAppendChar("m".to_string())); @@ -1620,6 +1664,9 @@ mod tests { // Submit triggers MTP let _ = app.update(Message::CommandSubmit); - assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("MTP") + ); } } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index af720ea76..0600de34d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -8,9 +8,10 @@ impl OpenCADStudio { let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else { return false; }; - self.command_line.push_info(crate::tf!( - "Object is on locked layer \"{layer}\" — unlock the layer to edit it." - ).as_ref()); + self.command_line.push_info( + crate::tf!("Object is on locked layer \"{layer}\" — unlock the layer to edit it.") + .as_ref(), + ); true } @@ -42,19 +43,13 @@ impl OpenCADStudio { let endpoint = |entity: &acadrust::EntityType| { let last_grip = match entity { acadrust::EntityType::Line(line) => { - return Some(glam::DVec3::new( - line.end.x, - line.end.y, - line.end.z, - )); + return Some(glam::DVec3::new(line.end.x, line.end.y, line.end.z)); } acadrust::EntityType::Arc(_) => Some(2), acadrust::EntityType::LwPolyline(polyline) => { polyline.vertices.len().checked_sub(1) } - acadrust::EntityType::Polyline(polyline) => { - polyline.vertices.len().checked_sub(1) - } + acadrust::EntityType::Polyline(polyline) => polyline.vertices.len().checked_sub(1), acadrust::EntityType::Polyline2D(polyline) => { polyline.vertices.len().checked_sub(1) } @@ -212,9 +207,7 @@ impl OpenCADStudio { .solid_history_operation(handle) .cloned() .is_some_and(|operation| { - self.tabs[i] - .scene - .rebuild_solid_history(handle, operation) + self.tabs[i].scene.rebuild_solid_history(handle, operation) }); if !restored { self.tabs[i].scene.reseed_derived_caches(handle); @@ -267,8 +260,10 @@ impl OpenCADStudio { if self.tabs[i].active_cmd.is_some() { tasks.push(self.apply_cmd_result(CmdResult::CancelForSpaceChange)); } else if message_pending { - self.command_line - .push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref()); + self.command_line.push_info( + crate::t!("Command cancelled because the active drawing space changed.") + .as_ref(), + ); } cancellation_reported = true; } @@ -281,8 +276,9 @@ impl OpenCADStudio { self.mtext_cancel(); } if !cancellation_reported && (grip_cancelled || suspended_cancelled || editor_cancelled) { - self.command_line - .push_info(crate::t!("Command cancelled because the active drawing space changed.").as_ref()); + self.command_line.push_info( + crate::t!("Command cancelled because the active drawing space changed.").as_ref(), + ); } self.command_line.input.clear(); @@ -306,7 +302,8 @@ impl OpenCADStudio { .is_some_and(|command| command.name() != "LIMITS") && self.tabs[i].scene.drawing_limit_check_enabled(); if checks_limits && !self.tabs[i].scene.point_inside_drawing_limits(point) { - self.command_line.push_error(crate::t!("Outside limits.").as_ref()); + self.command_line + .push_error(crate::t!("Outside limits.").as_ref()); return false; } true @@ -314,15 +311,24 @@ impl OpenCADStudio { /// Refresh screen-space feature picking immediately before point dispatch. pub(super) fn refresh_command_point_pick_context(&mut self, i: usize) { - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.wants_point_pick_context()) { let scene = &self.tabs[i].scene; let size = scene.selection.borrow().vp_size; let edit = scene.viewport_edit_frame(size); - let tile = edit.as_ref().map(|(_, rect)| *rect) + let tile = edit + .as_ref() + .map(|(_, rect)| *rect) .unwrap_or_else(|| scene.active_model_tile_bounds(size.0, size.1)); - let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: tile.width, height: tile.height }; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: tile.width, + height: tile.height, + }; let context = if bounds.width > 0.0 && bounds.height > 0.0 { let (view, eye) = if let Some((camera, _)) = edit { (camera.view_proj_rte(bounds), camera.eye()) @@ -330,9 +336,15 @@ impl OpenCADStudio { let camera = scene.camera.borrow(); (camera.view_proj_rte(bounds), camera.eye()) }; - Some(crate::command::PointPickContext { view, eye, bounds, - aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box) }) - } else { None }; + Some(crate::command::PointPickContext { + view, + eye, + bounds, + aperture_px: crate::ui::overlay::pick_box_aperture_px(self.pick_box), + }) + } else { + None + }; if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_point_pick_context(context); } @@ -406,7 +418,9 @@ impl OpenCADStudio { if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_entity_pick_direction(direction); } - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.inject_before_entity_pick()) { if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() { @@ -417,7 +431,9 @@ impl OpenCADStudio { } } if let StepInput::SelectionComplete(handles) = &input { - let exclude_locked = self.tabs[i].active_cmd.as_ref() + let exclude_locked = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.selection_entities_exclude_locked()); let entities = { let scene = &self.tabs[i].scene; @@ -444,7 +460,9 @@ impl OpenCADStudio { command.inject_selection_entities(entities); } } - if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } + if matches!(&input, StepInput::Point(_)) { + self.refresh_command_point_pick_context(i); + } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -611,11 +629,14 @@ impl OpenCADStudio { _ => return None, }; if add.is_empty() { - self.command_line.push_info(crate::t!(match kw.as_str() { - "P" | "PREVIOUS" => "No previous selection set.", - "ALL" => "Nothing to select.", - _ => "No last object.", - }).as_ref()); + self.command_line.push_info( + crate::t!(match kw.as_str() { + "P" | "PREVIOUS" => "No previous selection set.", + "ALL" => "Nothing to select.", + _ => "No last object.", + }) + .as_ref(), + ); return Some(Task::none()); } let count = add.len(); @@ -790,8 +811,7 @@ impl OpenCADStudio { let tab = &mut self.tabs[self.active_tab]; let changed = tab.scene.document.header.sketch_type != sketch_type || (tab.scene.document.header.sketch_increment - increment).abs() > f64::EPSILON - || (tab.scene.document.header.sketch_tolerance - tolerance).abs() - > f64::EPSILON; + || (tab.scene.document.header.sketch_tolerance - tolerance).abs() > f64::EPSILON; if changed { crate::io::set_sketch_settings( &mut tab.scene.document, @@ -947,12 +967,18 @@ impl OpenCADStudio { } /// Removes and re-solves the first conflicting constraint in the active scope. - pub(super) fn resolve_one_sketch_conflict(&mut self) { + pub(super) fn resolve_one_parametric_conflict(&mut self) { let i = self.active_tab; - let scope = self.tabs[i].current_sketch_scope(); - let Some(set) = self.tabs[i].scene.sketch_constraint_set(scope) else { return }; - let Some(&(id, _kind)) = set.conflicts.first() else { return }; - let Some(constraint) = set.get(id) else { return }; + let scope = self.tabs[i].current_parametric_scope(); + let Some(set) = self.tabs[i].scene.parametric_constraint_set(scope) else { + return; + }; + let Some(&(id, _kind)) = set.conflicts.first() else { + return; + }; + let Some(constraint) = set.get(id) else { + return; + }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); let label = "Remove conflicting constraint"; @@ -960,10 +986,15 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, constraints_before); - self.tabs[i].scene.sketch_constraint_set_mut(scope).remove(id); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .remove(id); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -978,7 +1009,9 @@ impl OpenCADStudio { // one arbitrarily (`ParameterTable::set` would just let the last // one silently win) -- refuse every row sharing that name up front, // same as `named_parameters::preview`'s live check. - let duplicates = crate::ui::window::named_parameters::duplicate_name_rows(&self.named_parameter_editor_rows); + let duplicates = crate::ui::window::named_parameters::duplicate_name_rows( + &self.named_parameter_editor_rows, + ); let mut table = crate::scene::named_parameters::ParameterTable::new(); let mut failed = 0usize; for (idx, row) in self.named_parameter_editor_rows.iter().enumerate() { @@ -987,12 +1020,19 @@ impl OpenCADStudio { continue; } if let Err(e) = table.set(name, row.formula.trim()) { - self.command_line.push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", name, e).as_ref()); failed += 1; } } if !duplicates.is_empty() { - self.command_line.push_error(crate::tf!("{} row(s) skipped: duplicate parameter name.", duplicates.len()).as_ref()); + self.command_line.push_error( + crate::tf!( + "{} row(s) skipped: duplicate parameter name.", + duplicates.len() + ) + .as_ref(), + ); failed += duplicates.len(); } let param_count = table.len(); @@ -1004,10 +1044,16 @@ impl OpenCADStudio { // incremental" philosophy). let touched: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .flat_map(|set| set.constraints.iter()) - .filter(|c| c.enabled && matches!(c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(_)))) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); @@ -1016,15 +1062,20 @@ impl OpenCADStudio { self.tabs[i].scene.named_parameters = table; self.tabs[i].dirty = true; if !touched.is_empty() { - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); + } else { + self.tabs[i].scene.sync_native_parametric_graph(); } if let Some(pd) = pending { self.commit_undo_delta(i, pd); } if failed == 0 { - self.command_line.push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); + self.command_line + .push_info(crate::tf!("{} parameter(s) applied.", param_count).as_ref()); } } @@ -1037,7 +1088,7 @@ impl OpenCADStudio { fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { let touched: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .flat_map(|set| set.constraints.iter()) .filter(|c| { @@ -1048,10 +1099,13 @@ impl OpenCADStudio { .collect(); self.tabs[i].dirty = true; if touched.is_empty() { + self.tabs[i].scene.sync_native_parametric_graph(); return; } - let changes: Vec<(Handle, crate::scene::ChangeKind)> = - touched.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); self.tabs[i].scene.bump_entities(&changes); } @@ -1080,7 +1134,13 @@ impl OpenCADStudio { return Task::none(); }; let typed = typed.trim().to_string(); - let Some(current) = self.tabs[i].scene.named_parameters().iter().nth(index).cloned() else { + let Some(current) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .cloned() + else { self.refresh_properties(); return Task::none(); }; @@ -1095,7 +1155,8 @@ impl OpenCADStudio { return Task::none(); } ParamField::Name if self.tabs[i].scene.named_parameters().contains(&typed) => { - self.command_line.push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); + self.command_line + .push_error(crate::tf!("Parameter '{}' already exists.", typed).as_ref()); self.refresh_properties(); return Task::none(); } @@ -1106,13 +1167,27 @@ impl OpenCADStudio { self.tabs[i].scene.record_undo_named_parameters_before(); let (result, resolve_name) = match field { - ParamField::Formula => - (self.tabs[i].scene.named_parameters_mut().set(¤t.name, &typed), current.name.clone()), + ParamField::Formula => ( + self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, &typed), + current.name.clone(), + ), ParamField::Name => { - self.tabs[i].scene.named_parameters_mut().remove(¤t.name); - let outcome = self.tabs[i].scene.named_parameters_mut().set(&typed, ¤t.source); + self.tabs[i] + .scene + .named_parameters_mut() + .remove(¤t.name); + let outcome = self.tabs[i] + .scene + .named_parameters_mut() + .set(&typed, ¤t.source); if outcome.is_err() { - let _ = self.tabs[i].scene.named_parameters_mut().set(¤t.name, ¤t.source); + let _ = self.tabs[i] + .scene + .named_parameters_mut() + .set(¤t.name, ¤t.source); } (outcome, typed.clone()) } @@ -1120,7 +1195,8 @@ impl OpenCADStudio { if let Err(e) = result { self.tabs[i].scene.take_undo_recording(); - self.command_line.push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); + self.command_line + .push_error(crate::tf!("Parameter '{}': {}", current.name, e).as_ref()); self.refresh_properties(); return Task::none(); } @@ -1128,7 +1204,7 @@ impl OpenCADStudio { if field == ParamField::Name { let affected: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .enumerate() .filter_map(|(index, set)| { @@ -1141,11 +1217,17 @@ impl OpenCADStudio { }) .collect(); for set_index in affected { - let scope = self.tabs[i].scene.sketch_constraints[set_index].scope; - let before = self.tabs[i].scene.sketch_constraints[set_index].clone(); - self.tabs[i].scene.record_undo_sketch_constraints_before(scope, before); - for constraint in &mut self.tabs[i].scene.sketch_constraints[set_index].constraints { - if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = &mut constraint.driving_param { + let scope = self.tabs[i].scene.parametric_constraints[set_index].scope; + let before = self.tabs[i].scene.parametric_constraints[set_index].clone(); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + for constraint in + &mut self.tabs[i].scene.parametric_constraints[set_index].constraints + { + if let Some(crate::scene::named_parameters::DrivingValue::Named(name)) = + &mut constraint.driving_param + { if name == ¤t.name { *name = typed.clone(); } @@ -1168,7 +1250,13 @@ impl OpenCADStudio { /// special-case handling here. pub(super) fn on_prop_param_delete(&mut self, index: usize) -> Task { let i = self.active_tab; - let Some(name) = self.tabs[i].scene.named_parameters().iter().nth(index).map(|p| p.name.clone()) else { + let Some(name) = self.tabs[i] + .scene + .named_parameters() + .iter() + .nth(index) + .map(|p| p.name.clone()) + else { return Task::none(); }; let pending = self.begin_undo(i, "Delete named parameter", 0, true); @@ -1206,6 +1294,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "Add named parameter", 0, true); self.tabs[i].scene.record_undo_named_parameters_before(); let _ = self.tabs[i].scene.named_parameters_mut().set(&name, "1"); + self.tabs[i].scene.sync_native_parametric_graph(); self.tabs[i].dirty = true; if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -1234,25 +1323,47 @@ impl OpenCADStudio { handle: Handle, mut entities: Vec, ) -> Vec { - let same_type = entities.len() == 1 && self.tabs[tab].scene.document.get_entity(handle) - .is_some_and(|original| std::mem::discriminant(original) == std::mem::discriminant(&entities[0])); + let same_type = entities.len() == 1 + && self.tabs[tab] + .scene + .document + .get_entity(handle) + .is_some_and(|original| { + std::mem::discriminant(original) == std::mem::discriminant(&entities[0]) + }); let handles = if same_type { let mut entity = entities.pop().expect("one replacement"); entity.common_mut().handle = handle; // Scene::update_entity also retains the live owning block and // refreshes dependent render caches without erase notifications. - if self.tabs[tab].scene.update_entity(entity) { vec![handle] } else { Vec::new() } + if self.tabs[tab].scene.update_entity(entity) { + vec![handle] + } else { + Vec::new() + } } else { - let owner = self.tabs[tab].scene.document.get_entity(handle).map(|entity| entity.common().owner_handle); + let owner = self.tabs[tab] + .scene + .document + .get_entity(handle) + .map(|entity| entity.common().owner_handle); self.tabs[tab].scene.erase_entities(&[handle]); - entities.into_iter().map(|mut entity| { - entity.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { entity.common_mut().owner_handle = owner; } - self.tabs[tab].scene.add_entity(entity) - }).collect() + entities + .into_iter() + .map(|mut entity| { + entity.common_mut().handle = Handle::NULL; + if let Some(owner) = owner { + entity.common_mut().owner_handle = owner; + } + self.tabs[tab].scene.add_entity(entity) + }) + .collect() }; for &updated in &handles { - if matches!(self.tabs[tab].scene.document.get_entity(updated), Some(acadrust::EntityType::Dimension(_))) { + if matches!( + self.tabs[tab].scene.document.get_entity(updated), + Some(acadrust::EntityType::Dimension(_)) + ) { self.tabs[tab].scene.invalidate_dim_block_recorded(updated); } } @@ -1270,8 +1381,7 @@ impl OpenCADStudio { if !matches!( &op, PeditOp::Multiple(_, _) | PeditOp::JoinSelection(_, _, _) - ) - && self.reject_locked_edit(tab, handle) + ) && self.reject_locked_edit(tab, handle) { return Task::none(); } @@ -1291,7 +1401,10 @@ impl OpenCADStudio { .map(|entity| (*handle, entity)) }) .collect::>(); - let connector_distance = crate::modules::draw::modify::pedit::selection_connector_distance(&available, *fuzz); + let connector_distance = + crate::modules::draw::modify::pedit::selection_connector_distance( + &available, *fuzz, + ); let mut changes = Vec::new(); while !available.is_empty() { let (source_handle, source) = available.remove(0); @@ -1343,8 +1456,11 @@ impl OpenCADStudio { .map(|replacement| (*handle, replacement, true)) } else { let mut replacement = original.clone(); - apply_pedit(&mut replacement, operation) - .then_some((*handle, replacement, false)) + apply_pedit(&mut replacement, operation).then_some(( + *handle, + replacement, + false, + )) } }) .collect(); @@ -1426,14 +1542,12 @@ impl OpenCADStudio { .and_then(convert_to_polyline); match converted { Some(polyline) => { - return self.apply_cmd_result(CmdResult::ReplaceEntity( - handle, - vec![polyline], - )); + return self + .apply_cmd_result(CmdResult::ReplaceEntity(handle, vec![polyline])); } - None => self.command_line.push_error( - crate::t!("PEDIT: cannot convert this entity.").as_ref(), - ), + None => self + .command_line + .push_error(crate::t!("PEDIT: cannot convert this entity.").as_ref()), } } _ => { @@ -1468,7 +1582,11 @@ impl OpenCADStudio { } } } - if let Some(prompt) = self.tabs[tab].active_cmd.as_ref().map(|command| command.prompt()) { + if let Some(prompt) = self.tabs[tab] + .active_cmd + .as_ref() + .map(|command| command.prompt()) + { self.command_line.push_info(&prompt); } Task::none() @@ -1476,18 +1594,24 @@ impl OpenCADStudio { fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; - if let Some(bind) = self.tabs[i].active_cmd.as_ref().and_then(|command| command.nested_copy_bind_setting()) { + if let Some(bind) = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_bind_setting()) + { if self.ncopy_bind != bind { self.ncopy_bind = bind; self.persist_settings_if_changed(); } } - let preserve_commit_style = self.tabs[i].active_cmd.as_ref() + let preserve_commit_style = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.preserve_commit_style()); let preserve_commit_layer = self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.preserve_commit_layer()); + .active_cmd + .as_ref() + .is_some_and(|command| command.preserve_commit_layer()); match result { CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to @@ -1590,9 +1714,7 @@ impl OpenCADStudio { }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.tabs[i].scene.infer_dimension_sources(handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -1620,14 +1742,19 @@ impl OpenCADStudio { return Task::none(); } let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i].active_cmd.as_ref() - .and_then(|command| command.nested_copy_symbol_names()).cloned(); - let delta_safe = symbol_names.is_none() && entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { - let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); if retained > 0 { self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); } @@ -1654,14 +1781,19 @@ impl OpenCADStudio { } CmdResult::CommitEntitiesAndExit(mut entities) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let symbol_names = self.tabs[i].active_cmd.as_ref() - .and_then(|command| command.nested_copy_symbol_names()).cloned(); - let delta_safe = symbol_names.is_none() && entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let symbol_names = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.nested_copy_symbol_names()) + .cloned(); + let delta_safe = symbol_names.is_none() + && entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); if let Some(names) = symbol_names { - let retained = self.tabs[i].scene.document.localize_nested_copy_symbols(&mut entities, &names); + let retained = self.tabs[i] + .scene + .document + .localize_nested_copy_symbols(&mut entities, &names); if retained > 0 { self.command_line.push_info(&format!("{} copied object(s) retain imported style references whose dependencies cannot be localized.", retained)); } @@ -1703,9 +1835,7 @@ impl OpenCADStudio { }); let label = self.history_label_from_active_cmd(i, "MVIEW"); let pending = self.begin_undo(i, label, 1, true); - let handle = self.commit_entity_handle( - acadrust::EntityType::Viewport(viewport), - ); + let handle = self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); if let (Some(handle), Some(saved)) = (handle, saved_view) { if let Some(acadrust::EntityType::Viewport(viewport)) = self.tabs[i].scene.document.get_entity_mut(handle) @@ -1736,36 +1866,27 @@ impl OpenCADStudio { } => { if boundary.is_none() { let scene = &self.tabs[i].scene; - let valid = scene - .entity_belongs_to_current_layout(boundary_handle) + let valid = scene.entity_belongs_to_current_layout(boundary_handle) && scene - .document - .get_entity(boundary_handle) - .is_some_and(|entity| { - match entity { + .document + .get_entity(boundary_handle) + .is_some_and(|entity| match entity { acadrust::EntityType::Circle(_) => true, acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(), - acadrust::EntityType::LwPolyline(polyline) => { - polyline.is_closed - } - acadrust::EntityType::Polyline(polyline) => { - polyline.is_closed() - } - acadrust::EntityType::Polyline2D(polyline) => { - polyline.is_closed() - } - acadrust::EntityType::Polyline3D(polyline) => { - polyline.flags.closed - } + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + acadrust::EntityType::Polyline3D(polyline) => polyline.flags.closed, _ => false, - } - }); + }); if !valid { self.command_line.push_error( crate::t!("MVIEW Object: select a closed paper-space circle, ellipse, or polyline.").as_ref(), ); - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -1798,9 +1919,7 @@ impl OpenCADStudio { } None => boundary_handle, }; - let polygon = self.tabs[i] - .scene - .clip_boundary_polygon(clip_handle, 0.0); + let polygon = self.tabs[i].scene.clip_boundary_polygon(clip_handle, 0.0); let bounds: Option<(f64, f64, f64, f64)> = polygon.iter().fold(None, |bounds, point| { if !point[0].is_finite() || !point[1].is_finite() { @@ -1822,8 +1941,9 @@ impl OpenCADStudio { }) }); let Some((min_x, min_y, max_x, max_y)) = bounds else { - self.command_line - .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.command_line.push_error( + crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), + ); self.tabs[i].active_cmd = None; if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -1831,8 +1951,9 @@ impl OpenCADStudio { return Task::none(); }; if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 { - self.command_line - .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + self.command_line.push_error( + crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref(), + ); self.tabs[i].active_cmd = None; if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -1850,9 +1971,8 @@ impl OpenCADStudio { viewport.height = max_y - min_y; viewport.id = 2; viewport.clip_boundary_handle = clip_handle; - let viewport_handle = self.commit_entity_handle( - acadrust::EntityType::Viewport(viewport), - ); + let viewport_handle = + self.commit_entity_handle(acadrust::EntityType::Viewport(viewport)); if let Some(viewport_handle) = viewport_handle { if !created_boundary { let before = self.tabs[i] @@ -1861,12 +1981,9 @@ impl OpenCADStudio { .get_entity(clip_handle) .cloned() .map(std::sync::Arc::new); - self.tabs[i] - .scene - .record_undo_before(clip_handle, before); + self.tabs[i].scene.record_undo_before(clip_handle, before); } - if let Some(boundary) = - self.tabs[i].scene.document.get_entity_mut(clip_handle) + if let Some(boundary) = self.tabs[i].scene.document.get_entity_mut(clip_handle) { let common = boundary.common_mut(); common.invisible = true; @@ -1874,10 +1991,9 @@ impl OpenCADStudio { common.reactors.push(viewport_handle); } } - self.tabs[i].scene.bump_entities(&[( - clip_handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(clip_handle, crate::scene::ChangeKind::Modified)]); } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); @@ -1898,9 +2014,7 @@ impl OpenCADStudio { .entity_belongs_to_active_space(handle) .then(|| scene.document.get_entity(handle)) .flatten() - .and_then( - crate::modules::draw::draw::wipeout::wipeout_from_polyline, - ) + .and_then(crate::modules::draw::draw::wipeout::wipeout_from_polyline) }; if let Some(wipeout) = wipeout { if erase_source { @@ -1914,17 +2028,17 @@ impl OpenCADStudio { self.command_line.push_error( crate::t!("WIPEOUT Polyline: select a straight, closed, planar 2D polyline with at least 3 non-intersecting vertices.").as_ref(), ); - let command = - crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); - self.command_line.push_info( - &crate::command::CadCommand::prompt(&command), - ); + let command = crate::modules::draw::draw::wipeout::WipeoutCommand::new_polyline(); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); self.tabs[i].active_cmd = Some(Box::new(command)); } CmdResult::MviewSwitchLayout(layout) => { let task = self.on_layout_switch_preserving_command(layout); - if let Some(prompt) = - self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + if let Some(prompt) = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()) { self.command_line.push_info(&prompt); } @@ -1987,10 +2101,8 @@ impl OpenCADStudio { } CmdResult::CopyToClipboard { handles, base } => { let count = self.copy_entities_to_clipboard(i, &handles, base); - self.command_line.push_info(crate::tf!( - "{} object(s) copied to clipboard.", - count - ).as_ref()); + self.command_line + .push_info(crate::tf!("{} object(s) copied to clipboard.", count).as_ref()); let prompt = self.tabs[i] .active_cmd .as_ref() @@ -2063,9 +2175,7 @@ impl OpenCADStudio { let committed = self.commit_entity_handle(entity); if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.tabs[i].scene.infer_dimension_sources(handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2090,16 +2200,10 @@ impl OpenCADStudio { continue_command, } => { let label = self.history_label_from_active_cmd(i, "DIMENSION"); - let association_mode = self.tabs[i] - .scene - .document - .header - .dimension_associativity; + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; let single_source_dimension = matches!( &entity, - acadrust::EntityType::Dimension( - acadrust::entities::Dimension::Ordinate(_) - ) + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) ); let inherited_dimension = if preserve_base_style { match &entity { @@ -2152,9 +2256,7 @@ impl OpenCADStudio { &entity, &self.tabs[i].scene.document, ); - let delta_safe = pieces - .iter() - .all(|piece| self.delta_add_safe(i, piece)); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); let pending = self.begin_undo(i, label, pieces.len(), delta_safe); for piece in pieces { self.tabs[i].scene.add_entity(piece); @@ -2163,22 +2265,15 @@ impl OpenCADStudio { } else { let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = self.commit_entity_handle_with_dimension_policy( - entity, - preserve_base_style, - ) { + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { if association_mode == 2 { - let mut changes = vec![ - (handle, crate::scene::ChangeKind::Modified), - ]; + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; match association { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( - || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) - }, + || self.tabs[i].scene.infer_dimension_sources(handle), |source| { if single_source_dimension { vec![Some(source)] @@ -2195,9 +2290,10 @@ impl OpenCADStudio { })); } crate::command::DimensionAssociationInput::Explicit(sources) => { - self.tabs[i] - .scene - .attach_dimension_association_sources(handle, sources.clone()); + self.tabs[i].scene.attach_dimension_association_sources( + handle, + sources.clone(), + ); changes.extend(sources.into_iter().flatten().map(|source| { (source.handle, crate::scene::ChangeKind::Modified) })); @@ -2217,11 +2313,7 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } if continue_command { - if let Some(prompt) = self.tabs[i] - .active_cmd - .as_ref() - .map(|cmd| cmd.prompt()) - { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { self.command_line.push_info(&prompt); } } else { @@ -2230,11 +2322,7 @@ impl OpenCADStudio { } } CmdResult::CommitDimensionsAndExit(dimensions) => { - let association_mode = self.tabs[i] - .scene - .document - .header - .dimension_associativity; + let association_mode = self.tabs[i].scene.document.header.dimension_associativity; let mut made = 0; let pending = if association_mode == 0 { let mut pieces = Vec::new(); @@ -2277,9 +2365,7 @@ impl OpenCADStudio { } pieces.extend(exploded); } - let delta_safe = pieces - .iter() - .all(|piece| self.delta_add_safe(i, piece)); + let delta_safe = pieces.iter().all(|piece| self.delta_add_safe(i, piece)); let pending = self.begin_undo(i, "QDIM", pieces.len(), delta_safe); for piece in pieces { self.tabs[i].scene.add_entity(piece); @@ -2330,9 +2416,12 @@ impl OpenCADStudio { .scene .attach_dimension_association_sources(handle, sources.clone()); let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; - changes.extend(sources.into_iter().flatten().map(|source| { - (source.handle, crate::scene::ChangeKind::Modified) - })); + changes.extend( + sources + .into_iter() + .flatten() + .map(|source| (source.handle, crate::scene::ChangeKind::Modified)), + ); changes.sort_by_key(|(handle, _)| handle.value()); changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); @@ -2355,7 +2444,10 @@ impl OpenCADStudio { CmdResult::SetQuickDimensionSnapPriority(priority) => { self.quick_dimension_snap_priority = priority.min(1); self.persist_settings_if_changed(); - let prompt = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()); + let prompt = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.prompt()); if let Some(prompt) = prompt { self.command_line.push_info(&prompt); } @@ -2376,9 +2468,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLEADERALIGN"); if apply_mleader_align(&mut self.tabs[i].scene, &handles, from, to) { self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("MLEADERALIGN Leaders aligned.").as_ref(), - ); + self.command_line + .push_output(crate::t!("MLEADERALIGN Leaders aligned.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); @@ -2392,9 +2483,8 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "MLEADERCOLLECT"); if apply_mleader_collect(&mut self.tabs[i].scene, &compatible, point) { self.tabs[i].dirty = true; - self.command_line.push_output( - crate::t!("MLEADERCOLLECT Leaders collected.").as_ref(), - ); + self.command_line + .push_output(crate::t!("MLEADERCOLLECT Leaders collected.").as_ref()); } } self.tabs[i].scene.clear_preview_wire(); @@ -2410,15 +2500,10 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let erase_source = erase_source.filter(|handle| { - self.delete_objects != 0 - && !self.tabs[i].scene.is_layer_locked(*handle) + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); - let pending = self.begin_undo( - i, - label, - 1 + usize::from(erase_source.is_some()), - true, - ); + let pending = + self.begin_undo(i, label, 1 + usize::from(erase_source.is_some()), true); let handle = self.add_solid_model(entity, *solid, history); if !handle.is_null() { if let Some(source) = erase_source { @@ -2477,9 +2562,7 @@ impl OpenCADStudio { open_editor, } => { let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = entities - .iter() - .all(|entity| self.delta_add_safe(i, entity)); + let delta_safe = entities.iter().all(|entity| self.delta_add_safe(i, entity)); let pending = self.begin_undo(i, label, entities.len(), delta_safe); let mut edit_handle = None; let mut leader_handle = None; @@ -2509,14 +2592,11 @@ impl OpenCADStudio { // The LEADER may already have received its annotation context while // annotation_handle was still NULL. Refresh it now that the MTEXT link // is known so the context represents the finished leader. + self.tabs[i].scene.sync_displayed_annotation_context(lh); + self.tabs[i] .scene - .sync_displayed_annotation_context(lh); - - self.tabs[i].scene.bump_entities(&[( - lh, - crate::scene::ChangeKind::Modified, - )]); + .bump_entities(&[(lh, crate::scene::ChangeKind::Modified)]); } } self.tabs[i].dirty = true; @@ -2549,15 +2629,12 @@ impl OpenCADStudio { let ucs = self.tabs[i].ucs_xform(); let world_to_block = ucs.to_ucs_transform_at(base); let block_to_world = ucs.to_wcs_transform_at(base); - match self.tabs[i] - .scene - .create_block_from_entities( - &handles, - &name, - &world_to_block, - &block_to_world, - ) - { + match self.tabs[i].scene.create_block_from_entities( + &handles, + &name, + &world_to_block, + &block_to_world, + ) { Ok(insert_handle) => { self.tabs[i].dirty = true; self.tabs[i].scene.deselect_all(); @@ -2607,11 +2684,10 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "HATCH"); let pending = self.begin_undo(i, label, 1, true); let layer = self.tabs[i].active_layer.clone(); - let new_handle = self.tabs[i].scene.add_hatch( - hatch, - Some(&layer), - Some((color, transparency)), - ); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), Some((color, transparency))); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2649,10 +2725,9 @@ impl OpenCADStudio { } hatch.boundary_sources = Some(std::sync::Arc::new(sources)); let layer = self.tabs[i].active_layer.clone(); - let new_handle = - self.tabs[i] - .scene - .add_hatch(hatch, Some(&layer), entity_style); + let new_handle = self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2674,11 +2749,10 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, hatches.len(), true); let layer = self.tabs[i].active_layer.clone(); for hatch in hatches { - let new_handle = self.tabs[i].scene.add_hatch( - hatch, - Some(&layer), - entity_style.clone(), - ); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style.clone()); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -2737,7 +2811,7 @@ impl OpenCADStudio { .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - self.replace_command_entity(i,handle,entities); + self.replace_command_entity(i, handle, entities); } for entity in additions { self.tabs[i].scene.add_entity(entity); @@ -2763,7 +2837,7 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { - let new_handles = self.replace_command_entity(i,handle,entities); + let new_handles = self.replace_command_entity(i, handle, entities); if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.on_entity_replaced(handle, &new_handles); } @@ -2807,31 +2881,40 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.refresh_properties(); } - CmdResult::AddSketchConstraint { kind, refs, driving_param, label } => { - if let Err(message) = self.tabs[i] - .scene - .validate_sketch_constraint(kind, &refs, driving_param.as_ref()) - { + CmdResult::AddParametricConstraint { + kind, + refs, + driving_param, + label, + } => { + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + driving_param.as_ref(), + ) { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.command_line.push_error(message); return Task::none(); } - let scope = self.tabs[i].current_sketch_scope(); - let constraints_before = self - .tabs[i] + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let touched: Vec = refs.iter().map(|r| r.entity).collect(); let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, constraints_before); - self.tabs[i].scene.sketch_constraint_set_mut(scope).add(kind, refs, driving_param); + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + driving_param, + ); let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) @@ -2846,19 +2929,32 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::AddCoincidentConstraint { point_a, point_b, label } => { - let scope = self.tabs[i].current_sketch_scope(); + CmdResult::AddCoincidentConstraint { + point_a, + point_b, + label, + } => { + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved_a = - crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_a), None); + let resolved_a = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point_a), + None, + ); let resolved_b = resolved_a.and_then(|ref_a| { - crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point_b), None) - .filter(|ref_b| *ref_b != ref_a) + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point_b), + None, + ) + .filter(|ref_b| *ref_b != ref_a) }); match (resolved_a, resolved_b) { (Some(ref_a), Some(ref_b)) => { - return self.apply_cmd_result(CmdResult::AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind::Coincident, + return self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Coincident, refs: vec![ref_a, ref_b], driving_param: None, label, @@ -2873,21 +2969,34 @@ impl OpenCADStudio { } } } - CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => { - let scope = self.tabs[i].current_sketch_scope(); + CmdResult::AddPointOnEntityConstraint { + point, + target, + kind, + label, + } => { + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); - let resolved = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(point), Some(target)); + let resolved = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(point), + Some(target), + ); match resolved { Some(point_ref) => { - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; // `CenterPoint` addresses its whole-entity side via // the center marker (same solve as Coincident / // Concentric — `ConstraintKind::CenterPoint`'s own // doc comment); `Midpoint`/`PointOnCurve` address it // as a whole entity. - let target_ref = - if kind == ConstraintKind::CenterPoint { SketchRef::center(target) } else { SketchRef::whole(target) }; - return self.apply_cmd_result(CmdResult::AddSketchConstraint { + let target_ref = if kind == ConstraintKind::CenterPoint { + ParametricRef::center(target) + } else { + ParametricRef::whole(target) + }; + return self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, refs: vec![point_ref, target_ref], driving_param: None, @@ -2904,20 +3013,24 @@ impl OpenCADStudio { } } CmdResult::AddEqualDistanceConstraint { points, label } => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let to_world = |p: glam::DVec3| acadrust::types::Vector3::new(p.x, p.y, p.z); let mut refs = Vec::with_capacity(4); for p in points { - let Some(r) = crate::scene::sketch_constraints::nearest_sketch_point(&self.tabs[i].scene.document, scope, to_world(p), None) - else { + let Some(r) = crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(p), + None, + ) else { refs.clear(); break; }; refs.push(r); } if refs.len() == 4 { - return self.apply_cmd_result(CmdResult::AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind::EqualDistance, + return self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::EqualDistance, refs, driving_param: None, label, @@ -2929,19 +3042,27 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; } - CmdResult::ReassociateCenterMark { target, source, point } => { + CmdResult::ReassociateCenterMark { + target, + source, + point, + } => { if self.reject_locked_edit(i, target) { return Task::none(); } self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - if self.tabs[i].scene.reassociate_center_mark(target, source, point) { + if self.tabs[i] + .scene + .reassociate_center_mark(target, source, point) + { self.tabs[i].dirty = true; self.command_line.push_output( crate::t!("CENTERREASSOCIATE: center mark associated.").as_ref(), ); } else { self.command_line.push_error( - crate::t!("CENTERREASSOCIATE: the selected source is not circular.").as_ref(), + crate::t!("CENTERREASSOCIATE: the selected source is not circular.") + .as_ref(), ); } self.tabs[i].active_cmd = None; @@ -3077,12 +3198,23 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_dim_block_recorded(nh); } } - let pedit_entities = if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { - new_handles.iter().filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()).collect::>() - } else { Vec::new() }; + let pedit_entities = if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + new_handles + .iter() + .filter_map(|new| self.tabs[i].scene.document.get_entity(*new).cloned()) + .collect::>() + } else { + Vec::new() + }; if let Some(cmd) = &mut self.tabs[i].active_cmd { cmd.on_entity_replaced(handle, &new_handles); - for entity in pedit_entities { cmd.inject_picked_entity(entity); } + for entity in pedit_entities { + cmd.inject_picked_entity(entity); + } } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -3276,11 +3408,14 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_info(crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." - } else { - "Command cancelled." - }).as_ref()); + self.command_line.push_info( + crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }) + .as_ref(), + ); } } CmdResult::SelectByPath { @@ -3388,7 +3523,9 @@ impl OpenCADStudio { .objects .get(&style_handle) .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => Some(style), + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } _ => None, }) }); @@ -3406,15 +3543,13 @@ impl OpenCADStudio { match self.begin_table_cell_edit(i, handle, point) { TableCellEditStart::Started => {} TableCellEditStart::LockedCell => { - self.command_line.push_error( - crate::t!("The cell is content locked.").as_ref(), - ); + self.command_line + .push_error(crate::t!("The cell is content locked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } TableCellEditStart::NoCell => { - self.command_line.push_error( - crate::t!("No editable table cell picked.").as_ref(), - ); + self.command_line + .push_error(crate::t!("No editable table cell picked.").as_ref()); let _ = self.dispatch_command("TABLEDIT"); } } @@ -3451,7 +3586,8 @@ impl OpenCADStudio { .push_info(crate::tf!("Layer matched to \"{layer}\".").as_ref()); self.sync_ribbon_layers(); } else { - self.command_line.push_error(crate::t!("Source object not found.").as_ref()); + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); } } CmdResult::MatchProperties { mut dest, src } => { @@ -3588,15 +3724,17 @@ impl OpenCADStudio { .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); self.tabs[i].scene.bump_entities(&changes); - self.command_line - .push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref()); + self.command_line.push_info( + crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref(), + ); // Clear the consumed target selection and keep prompting. self.tabs[i].scene.deselect_all(); if let Some(cmd) = &self.tabs[i].active_cmd { self.command_line.push_info(&cmd.prompt()); } } else { - self.command_line.push_error(crate::t!("Source object not found.").as_ref()); + self.command_line + .push_error(crate::t!("Source object not found.").as_ref()); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -3607,7 +3745,8 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); if self.clipboard.is_empty() { - self.command_line.push_error(crate::t!("Clipboard is empty.").as_ref()); + self.command_line + .push_error(crate::t!("Clipboard is empty.").as_ref()); } else { let delta = base_pt - self.clipboard_base; let translate = crate::command::EntityTransform::Translate(delta); @@ -3739,14 +3878,16 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "VPLAYER"); self.tabs[i].dirty = true; if frozen_count > 0 { - self.command_line.push_info(crate::tf!( - "VPLAYER: {frozen_count} layer(s) frozen in viewport." - ).as_ref()); + self.command_line.push_info( + crate::tf!("VPLAYER: {frozen_count} layer(s) frozen in viewport.") + .as_ref(), + ); } if thawed_count > 0 { - self.command_line.push_info(crate::tf!( - "VPLAYER: {thawed_count} layer(s) thawed in viewport." - ).as_ref()); + self.command_line.push_info( + crate::tf!("VPLAYER: {thawed_count} layer(s) thawed in viewport.") + .as_ref(), + ); } // Sync layer panel so VP freeze columns update immediately. let doc_layers = self.tabs[i].scene.document.layers.clone(); @@ -3771,7 +3912,8 @@ impl OpenCADStudio { self.tabs[i] .scene .zoom_to_window(p1.as_vec3(), p2.as_vec3()); - self.command_line.push_output(crate::t!("Zoom Window").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Window").as_ref()); } CmdResult::Measurement(msg) => { self.tabs[i].active_cmd = None; @@ -3877,7 +4019,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_output(crate::t!("ALIGN: applied.").as_ref()); + self.command_line + .push_output(crate::t!("ALIGN: applied.").as_ref()); self.refresh_properties(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); @@ -3905,7 +4048,8 @@ impl OpenCADStudio { self.tabs[i].scene.erase_entities(&[handle]); self.tabs[i].scene.add_entity(new_entity); self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LENGTHEN: applied.").as_ref()); + self.command_line + .push_output(crate::t!("LENGTHEN: applied.").as_ref()); self.refresh_properties(); } None => { @@ -3938,8 +4082,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("DIVIDE: {count} marker(s) placed.").as_ref()); } else { - self.command_line - .push_error(crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref()); + self.command_line.push_error( + crate::t!("DIVIDE: entity type not supported or N < 2.").as_ref(), + ); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -3981,24 +4126,53 @@ impl OpenCADStudio { } CmdResult::PeditOp { handle, op } => return self.apply_pedit_result(i, handle, op), CmdResult::JoinToSource { source, handles } => { - if self.reject_locked_edit(i, source) { return Task::none(); } - let joined = self.tabs[i].scene.document.get_entity(source).and_then(|entity| { - let candidates: Vec<_> = handles.iter().filter(|handle| **handle != source && !self.tabs[i].scene.is_layer_locked(**handle)) - .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).map(|entity| (*handle,entity))).collect(); - crate::modules::draw::modify::join::join_to_source(entity,&candidates) - }); + if self.reject_locked_edit(i, source) { + return Task::none(); + } + let joined = self.tabs[i] + .scene + .document + .get_entity(source) + .and_then(|entity| { + let candidates: Vec<_> = handles + .iter() + .filter(|handle| { + **handle != source && !self.tabs[i].scene.is_layer_locked(**handle) + }) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .map(|entity| (*handle, entity)) + }) + .collect(); + crate::modules::draw::modify::join::join_to_source(entity, &candidates) + }); if let Some((replacement, consumed)) = joined { - self.push_undo_snapshot(i,"JOIN"); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { *entity = replacement; } + self.push_undo_snapshot(i, "JOIN"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(source) { + *entity = replacement; + } self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.refresh_fill_model(source); - self.tabs[i].scene.bump_entities(&[(source,crate::scene::ChangeKind::Modified)]); + self.tabs[i] + .scene + .bump_entities(&[(source, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; - self.command_line.push_output(&format!("JOIN: {} objects joined to source.",consumed.len())); + self.command_line.push_output(&format!( + "JOIN: {} objects joined to source.", + consumed.len() + )); self.refresh_properties(); - } else { self.command_line.push_info("JOIN: no compatible objects joined to source."); } - self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + } else { + self.command_line + .push_info("JOIN: no compatible objects joined to source."); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); } CmdResult::JoinEntities(handles) => { if let Some(handle) = handles @@ -4028,9 +4202,10 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - self.command_line.push_output(crate::tf!( - "JOIN: {count_in} object(s) joined into {count_out}." - ).as_ref()); + self.command_line.push_output( + crate::tf!("JOIN: {count_in} object(s) joined into {count_out}.") + .as_ref(), + ); self.refresh_properties(); } None => { @@ -4056,12 +4231,14 @@ impl OpenCADStudio { .and_then(|e| break_entity(e, p1, p2)); match replacement { Some(frags) => { - let unchanged = frags.len() == 1 && self.tabs[i].scene.document.get_entity(handle) - .is_some_and(|original| { - let mut fragment = frags[0].clone(); - fragment.common_mut().handle = handle; - &fragment == original - }); + let unchanged = frags.len() == 1 + && self.tabs[i].scene.document.get_entity(handle).is_some_and( + |original| { + let mut fragment = frags[0].clone(); + fragment.common_mut().handle = handle; + &fragment == original + }, + ); if unchanged { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4073,7 +4250,10 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "BREAK"); self.push_undo_snapshot(i, label); let count = frags.len(); - let owner = self.tabs[i].scene.document.get_entity(handle) + let owner = self.tabs[i] + .scene + .document + .get_entity(handle) .map(|entity| entity.common().owner_handle); let mut fragments = frags.into_iter(); if let Some(mut first) = fragments.next() { @@ -4081,7 +4261,9 @@ impl OpenCADStudio { self.tabs[i].scene.update_entity(first); for mut fragment in fragments { fragment.common_mut().handle = Handle::NULL; - if let Some(owner) = owner { fragment.common_mut().owner_handle = owner; } + if let Some(owner) = owner { + fragment.common_mut().owner_handle = owner; + } self.tabs[i].scene.add_entity(fragment); } } else { @@ -4115,8 +4297,9 @@ impl OpenCADStudio { let x1 = p1.x.max(p2.x); let y1 = p1.y.max(p2.y); self.plot_window = Some((x0, y0, x1, y1)); - self.command_line - .push_output(crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref()); + self.command_line.push_output( + crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), + ); // Pick window closed the plot dialog so the viewport could // receive the two clicks — bring the dialog back with the // window now active. @@ -4133,9 +4316,9 @@ impl OpenCADStudio { let x2 = p1.x.max(p2.x); let y2 = p1.y.max(p2.y); self.plot_window = Some((x1, y1, x2, y2)); - self.command_line.push_output(crate::tf!( - "Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), + ); self.plot_dialog.area = "Window".to_string(); self.active_modal = Some(super::ModalKind::Plot); } @@ -4164,10 +4347,7 @@ impl OpenCADStudio { handles.extend( scene .interaction_handles_in_world_aabb([ - win_min.x, - win_min.y, - win_max.x, - win_max.y, + win_min.x, win_min.y, win_max.x, win_max.y, ]) .into_iter() .filter(|&handle| !scene.is_layer_locked(handle)), @@ -4179,9 +4359,8 @@ impl OpenCADStudio { handles.dedup(); if handles.is_empty() { - self.command_line.push_output( - crate::t!("STRETCH: nothing crosses the window.").as_ref(), - ); + self.command_line + .push_output(crate::t!("STRETCH: nothing crosses the window.").as_ref()); } else { // Visual feedback must only highlight entities that actually have a // stretchable point inside one of the crossing windows. `handles` can @@ -4264,11 +4443,7 @@ impl OpenCADStudio { let wires = self.tabs[i].scene.wire_models_for(&handles); - let cmd = StretchCommand::with_windows( - handles, - wires, - windows, - ); + let cmd = StretchCommand::with_windows(handles, wires, windows); self.command_line.push_info(&CadCommand::prompt(&cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -4297,10 +4472,7 @@ impl OpenCADStudio { // Drawing plane is world XY (= DXF XY). let in_win = |x: f64, y: f64| -> bool { windows.iter().any(|(win_min, win_max)| { - x >= win_min.x - && x <= win_max.x - && y >= win_min.y - && y <= win_max.y + x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y }) }; @@ -4335,8 +4507,7 @@ impl OpenCADStudio { } } acadrust::EntityType::LwPolyline(p) => { - let Some(mut world) = - crate::entities::curve::lwpolyline_world_xy(p) + let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) else { continue; }; @@ -4419,12 +4590,7 @@ impl OpenCADStudio { } acadrust::EntityType::Viewport(vp) => { stretched = windows.iter().any(|(win_min, win_max)| { - crate::entities::viewport::stretch( - vp, - *win_min, - *win_max, - delta, - ) + crate::entities::viewport::stretch(vp, *win_min, *win_max, delta) }); } acadrust::EntityType::Dimension(dim) => { @@ -4573,9 +4739,7 @@ impl OpenCADStudio { return Task::none(); } use crate::command::{ExtrudeExtent, ExtrudeMode}; - use crate::modules::insert::solid3d_cmds::{ - empty_extruded_surface, empty_solid3d, - }; + use crate::modules::insert::solid3d_cmds::{empty_extruded_surface, empty_solid3d}; use crate::scene::model::sweep_model; let path = match extent { ExtrudeExtent::Path(handle) => self.tabs[i] @@ -4597,7 +4761,8 @@ impl OpenCADStudio { let mut consumed = Vec::new(); let mut failed = 0usize; for handle in &handles { - let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() + else { failed += 1; continue; }; @@ -4627,20 +4792,16 @@ impl OpenCADStudio { (false, Some((_, path)), _) => { sweep_model::extruded_along_path(&entity, path, taper_angle) } - (false, None, Some(direction)) => { - sweep_model::extruded_direction( - &entity, - direction.to_array(), - taper_angle, - ) - } - (true, None, Some(direction)) => { - sweep_model::extruded_surface( - &entity, - direction.to_array(), - taper_angle, - ) - } + (false, None, Some(direction)) => sweep_model::extruded_direction( + &entity, + direction.to_array(), + taper_angle, + ), + (true, None, Some(direction)) => sweep_model::extruded_surface( + &entity, + direction.to_array(), + taper_angle, + ), (true, Some(_), _) => path_direction.and_then(|direction| { sweep_model::extruded_surface( &entity, @@ -4655,9 +4816,7 @@ impl OpenCADStudio { continue; }; let created = if creates_surface { - let direction = direction - .or(path_direction) - .unwrap_or(glam::DVec3::ZERO); + let direction = direction.or(path_direction).unwrap_or(glam::DVec3::ZERO); let history = path .is_none() .then(|| { @@ -4689,28 +4848,28 @@ impl OpenCADStudio { None => self.add_surface_model(surface, body), } } else { - let direction = direction.unwrap_or(glam::DVec3::ZERO); - let history = path - .as_ref() - .and_then(|(_, path)| { - sweep_model::sweep_history( - &entity, - path, - taper_angle, - profile.plane.origin, - ) - }) - .or_else(|| { - sweep_model::extrusion_history( - &entity, - None, - direction.to_array(), - taper_angle, - profile.plane.origin, - ) - }) + let direction = direction.unwrap_or(glam::DVec3::ZERO); + let history = path + .as_ref() + .and_then(|(_, path)| { + sweep_model::sweep_history( + &entity, + path, + taper_angle, + profile.plane.origin, + ) + }) + .or_else(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) .unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body)); - self.add_solid_model(empty_solid3d(), body, history) + self.add_solid_model(empty_solid3d(), body, history) }; if created.is_null() { failed += 1; @@ -4768,9 +4927,8 @@ impl OpenCADStudio { return Task::none(); } if !distance.is_finite() { - self.command_line.push_error( - crate::t!("Requires numeric distance or two points.").as_ref(), - ); + self.command_line + .push_error(crate::t!("Requires numeric distance or two points.").as_ref()); return Task::none(); } if distance.abs() <= f64::EPSILON { @@ -4789,13 +4947,12 @@ impl OpenCADStudio { failed += 1; continue; }; - let kernel_distance = if surface.kind - == acadrust::entities::SurfaceKind::Revolved - { - -distance - } else { - distance - }; + let kernel_distance = + if surface.kind == acadrust::entities::SurfaceKind::Revolved { + -distance + } else { + distance + }; let body = self.tabs[i] .scene .solid_models @@ -4852,10 +5009,19 @@ impl OpenCADStudio { self.refresh_properties(); } - CmdResult::PresspullPick { handle, point, offset, multiple: _ } => { + CmdResult::PresspullPick { + handle, + point, + offset, + multiple: _, + } => { self.presspull_pick(handle, point, offset); } - CmdResult::PresspullApply { targets, distance, color: _ } => { + CmdResult::PresspullApply { + targets, + distance, + color: _, + } => { self.presspull_apply(targets, distance); } @@ -4878,9 +5044,7 @@ impl OpenCADStudio { } let delete_sources = self.delete_objects != 0; use crate::command::ExtrudeMode; - use crate::modules::insert::solid3d_cmds::{ - empty_revolved_surface, empty_solid3d, - }; + use crate::modules::insert::solid3d_cmds::{empty_revolved_surface, empty_solid3d}; use crate::scene::model::sweep_model; let from = axis_start.to_array(); let to = axis_end.to_array(); @@ -4929,27 +5093,21 @@ impl OpenCADStudio { body, ) } else { - let result = sweep_model::revolve_history( - &entity, - from, - to, - angle, - start_angle, - ) - .and_then(|history| { - cadkernel::acis::rebuild_body(&history) - .ok() - .map(|solid| (solid, history)) - }) - .or_else(|| { - sweep_model::revolved(&entity, from, to, angle, start_angle).map( - |solid| { - let history = - crate::scene::model::solid_history::brep_op(&solid); - (solid, history) - }, - ) - }); + let result = + sweep_model::revolve_history(&entity, from, to, angle, start_angle) + .and_then(|history| { + cadkernel::acis::rebuild_body(&history) + .ok() + .map(|solid| (solid, history)) + }) + .or_else(|| { + sweep_model::revolved(&entity, from, to, angle, start_angle) + .map(|solid| { + let history = + crate::scene::model::solid_history::brep_op(&solid); + (solid, history) + }) + }); let Some((solid, history)) = result else { failed += 1; continue; @@ -5026,7 +5184,10 @@ impl OpenCADStudio { failed += 1; } } - let selection = profiles.iter().map(|(_, entity)| entity.clone()).collect::>(); + let selection = profiles + .iter() + .map(|(_, entity)| entity.clone()) + .collect::>(); let options = sweep_model::sweep_selection_options(&selection, options); let pending = if profiles.is_empty() { None @@ -5039,20 +5200,21 @@ impl OpenCADStudio { let result = path.as_ref().zip(options).and_then(|(path, options)| { let record = sweep_model::sweep_record(&profile, path, options)?; let (_, _, closed) = cadkernel::acis::sweep_profile_geometry( - record.sweep_entity.as_ref()?, record.sweep_entity_transform, - ).ok()?; + record.sweep_entity.as_ref()?, + record.sweep_entity_transform, + ) + .ok()?; let surface = mode == ExtrudeMode::Surface || !closed; - let body = cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; + let body = + cadkernel::acis::rebuild_sweep_with_mode(&record, surface).ok()?; Some((body, record, surface)) }); let Some((body, record, surface)) = result else { failed += 1; continue; }; - let deletes_path = crate::app::delobj_deletes_auxiliary( - delete_objects, - surface, - ); + let deletes_path = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { failed += 1; continue; @@ -5061,7 +5223,8 @@ impl OpenCADStudio { self.add_surface_model(sweep_model::swept_surface_entity(&record), body) } else { self.add_solid_model( - empty_solid3d(), body, + empty_solid3d(), + body, acadrust::objects::SolidHistoryOperation::Sweep(record), ) }; @@ -5091,7 +5254,9 @@ impl OpenCADStudio { created = created_handles.len(), failed = failed ).as_ref()); } else { - self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); + self.command_line.push_error( + crate::t!("SWEEP: could not sweep the profile along the path.").as_ref(), + ); } if let Some(pending) = pending { self.commit_undo_delta(i, pending); @@ -5104,39 +5269,69 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { sections, guides, path, mode, options, color: _ } => { + CmdResult::LoftEntities { + sections, + guides, + path, + mode, + options, + color: _, + } => { use crate::command::LoftSectionSelection; use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::loft_command_model; - let mut sources = sections.iter().flat_map(|section| match section { - LoftSectionSelection::Entity(handle) => vec![*handle], - LoftSectionSelection::Join(handles) => handles.clone(), - LoftSectionSelection::Point(_) => Vec::new(), - }).collect::>(); + let mut sources = sections + .iter() + .flat_map(|section| match section { + LoftSectionSelection::Entity(handle) => vec![*handle], + LoftSectionSelection::Join(handles) => handles.clone(), + LoftSectionSelection::Point(_) => Vec::new(), + }) + .collect::>(); sources.sort_unstable_by_key(|handle| handle.value()); sources.dedup(); let delete_objects = self.delete_objects; let delete_sections = delete_objects != 0; let section_locked = delete_sections - && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); - let available = sources.iter().chain(guides.iter()).copied().chain(path) - .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) - .cloned().map(|entity| (handle, entity))).collect::>(); + && sources + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let available = sources + .iter() + .chain(guides.iter()) + .copied() + .chain(path) + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect::>(); let result = if section_locked { Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) - .and_then(|record| cadkernel::acis::rebuild_loft_with_options(&record).map(|body| (body, record))) + .and_then(|record| { + cadkernel::acis::rebuild_loft_with_options(&record) + .map(|body| (body, record)) + }) }; match result { Ok((body, record)) => { - let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); - let delete_auxiliary = crate::app::delobj_deletes_auxiliary( - delete_objects, - surface, - ); + let surface = record + .parameters + .as_ref() + .is_some_and(|settings| settings.surface); + let delete_auxiliary = + crate::app::delobj_deletes_auxiliary(delete_objects, surface); if delete_auxiliary - && guides.iter().copied().chain(path) + && guides + .iter() + .copied() + .chain(path) .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) { self.command_line.push_error( @@ -5147,15 +5342,23 @@ impl OpenCADStudio { let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { - let handle = self.add_surface_model(loft_command_model::surface_entity(&record), body); + let handle = self.add_surface_model( + loft_command_model::surface_entity(&record), + body, + ); if !handle.is_null() { - self.tabs[i].scene.create_solid_history(handle, - acadrust::objects::SolidHistoryOperation::Loft(record)); + self.tabs[i].scene.create_solid_history( + handle, + acadrust::objects::SolidHistoryOperation::Loft(record), + ); } handle } else { - self.add_solid_model(empty_solid3d(), body, - acadrust::objects::SolidHistoryOperation::Loft(record)) + self.add_solid_model( + empty_solid3d(), + body, + acadrust::objects::SolidHistoryOperation::Loft(record), + ) }; if created.is_null() { // The creation helper already removed its provisional @@ -5182,10 +5385,14 @@ impl OpenCADStudio { self.tabs[i].dirty = true; let message = if surface { crate::t!("LOFT: surface created.") - } else { crate::t!("LOFT: solid created.") }; + } else { + crate::t!("LOFT: solid created.") + }; self.command_line.push_output(message.as_ref()); } - if let Some(pending) = pending { self.commit_undo_delta(i, pending); } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } } Err(error) => { self.command_line.push_error(&error); @@ -5207,14 +5414,8 @@ impl OpenCADStudio { other_value, fillet, } => { - let task = self.solid_edge_blend( - handle, - &edges, - base_face, - value, - other_value, - fillet, - ); + let task = + self.solid_edge_blend(handle, &edges, base_face, value, other_value, fillet); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -5292,39 +5493,63 @@ impl OpenCADStudio { .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); } else { use crate::command::{CadCommand, HatchEditOperation}; - if matches!(&operation,HatchEditOperation::BeginAssociate) { - let associative=matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); + if matches!(&operation, HatchEditOperation::BeginAssociate) { + let associative = matches!(self.tabs[i].scene.document.get_entity(handle),Some(acadrust::EntityType::Hatch(h)) if h.is_associative); if associative { - self.command_line.push_info("HATCHEDIT: hatch is already associative."); - self.tabs[i].active_cmd=None; - }else{ - let plane=match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h))=>{ - let storage=crate::entities::curve::ocs_plane(h.normal,h.elevation); - crate::command::WorkingPlane::new(glam::DVec3::from_array(storage.origin),glam::DVec3::from_array(storage.x_axis),glam::DVec3::from_array(storage.y_axis)) - }, - _=>self.tabs[i].ucs_xform().working_plane(), + self.command_line + .push_info("HATCHEDIT: hatch is already associative."); + self.tabs[i].active_cmd = None; + } else { + let plane = match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => { + let storage = + crate::entities::curve::ocs_plane(h.normal, h.elevation); + crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ) + } + _ => self.tabs[i].ucs_xform().working_plane(), }; - let mut sources=self.tabs[i].scene.boundary_sources_on_plane(plane,1e-6); + let mut sources = + self.tabs[i].scene.boundary_sources_on_plane(plane, 1e-6); sources.remove(&handle); let command=crate::modules::draw::draw::hatchedit::HatcheditCommand::for_association(handle,name,scale,angle,plane,sources); self.tabs[i].scene.deselect_all(); self.command_line.push_info(&command.prompt()); - self.tabs[i].active_cmd=Some(Box::new(command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } return Task::none(); } - if let HatchEditOperation::DrawOrderBoundary{above}=&operation { - let references:Vec<_>=match self.tabs[i].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Hatch(h))=>h.paths.iter().flat_map(|p|p.boundary_handles.iter()) - .filter(|h|self.tabs[i].scene.document.get_entity(**h).is_some()).map(|h|format!("{:X}",h.value())).collect(), - _=>Vec::new(), - }; - self.tabs[i].active_cmd=None; - if references.is_empty(){self.command_line.push_info("HATCHEDIT: no associated boundary objects.");return Task::none();} - self.tabs[i].scene.deselect_all();self.tabs[i].scene.select_entity(handle,false); - let command=format!("DRAWORDER {} {}",if *above{"ABOVE"}else{"UNDER"},references.join(" ")); - return self.dispatch_view(&command,i).unwrap_or_else(Task::none); + if let HatchEditOperation::DrawOrderBoundary { above } = &operation { + let references: Vec<_> = + match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Hatch(h)) => h + .paths + .iter() + .flat_map(|p| p.boundary_handles.iter()) + .filter(|h| { + self.tabs[i].scene.document.get_entity(**h).is_some() + }) + .map(|h| format!("{:X}", h.value())) + .collect(), + _ => Vec::new(), + }; + self.tabs[i].active_cmd = None; + if references.is_empty() { + self.command_line + .push_info("HATCHEDIT: no associated boundary objects."); + return Task::none(); + } + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + let command = format!( + "DRAWORDER {} {}", + if *above { "ABOVE" } else { "UNDER" }, + references.join(" ") + ); + return self.dispatch_view(&command, i).unwrap_or_else(Task::none); } if matches!( &operation, @@ -5342,20 +5567,43 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "HATCHEDIT"); match operation { - HatchEditOperation::Appearance { color, layer, transparency } => { - let layer=layer.map(|name|if name=="."{self.tabs[i].active_layer.clone()}else{name}); - if layer.as_ref().is_some_and(|name|self.tabs[i].scene.document.layers.get(name).is_none()) { + HatchEditOperation::Appearance { + color, + layer, + transparency, + } => { + let layer = layer.map(|name| { + if name == "." { + self.tabs[i].active_layer.clone() + } else { + name + } + }); + if layer.as_ref().is_some_and(|name| { + self.tabs[i].scene.document.layers.get(name).is_none() + }) { self.discard_last_undo_entry(i); self.command_line.push_error("HATCHEDIT: layer not found."); return Task::none(); } - if let Some(entity)=self.tabs[i].scene.document.get_entity_mut(handle) { - let common=entity.common_mut(); - if let Some(value)=color {common.color=value;common.color_name=None;common.color_book_handle=None;} - if let Some(value)=layer {common.layer=value;} - if let Some(value)=transparency {common.transparency=value;} + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) + { + let common = entity.common_mut(); + if let Some(value) = color { + common.color = value; + common.color_name = None; + common.color_book_handle = None; + } + if let Some(value) = layer { + common.layer = value; + } + if let Some(value) = transparency { + common.transparency = value; + } } - self.tabs[i].scene.bump_entities(&[(handle,crate::scene::ChangeKind::Modified)]); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.refresh_properties(); } HatchEditOperation::Update { @@ -5369,7 +5617,8 @@ impl OpenCADStudio { if let Some((x, y)) = origin { if !self.tabs[i].scene.document.set_hatch_origin([x, y]) { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: cannot store default origin."); + self.command_line + .push_error("HATCHEDIT: cannot store default origin."); return Task::none(); } } @@ -5381,7 +5630,10 @@ impl OpenCADStudio { if let Some(entry) = crate::scene::model::hatch_patterns::find(&name) { - let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + let mut pattern = + crate::scene::model::hatch_patterns::build_dxf_pattern( + entry, + ); crate::entities::hatch::scale_pattern_geometry( &mut pattern, scale.max(1.0e-6) as f64, @@ -5391,7 +5643,11 @@ impl OpenCADStudio { (angle as f64).to_radians(), ); let origin = hatch.pattern_origin(); - crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + origin.x, + origin.y, + ); hatch.pattern = pattern; hatch.is_solid = matches!( entry.gpu, @@ -5445,10 +5701,9 @@ impl OpenCADStudio { } } } - self.tabs[i].scene.bump_entities(&[( - handle, - crate::scene::ChangeKind::Modified, - )]); + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } HatchEditOperation::AddBoundaries(handles) => { self.tabs[i] @@ -5461,12 +5716,25 @@ impl OpenCADStudio { .edit_hatch_boundary_handles(handle, &handles, false); } HatchEditOperation::AssociatePaths(paths) => { - if paths.is_empty()||paths.iter().any(|path|path.boundary_handles.is_empty()||path.boundary_handles.iter().any(|source|self.tabs[i].scene.document.get_entity(*source).is_none())) { + if paths.is_empty() + || paths.iter().any(|path| { + path.boundary_handles.is_empty() + || path.boundary_handles.iter().any(|source| { + self.tabs[i] + .scene + .document + .get_entity(*source) + .is_none() + }) + }) + { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: associated boundary is no longer available."); + self.command_line.push_error( + "HATCHEDIT: associated boundary is no longer available.", + ); return Task::none(); } - self.tabs[i].scene.replace_hatch_association(handle,paths); + self.tabs[i].scene.replace_hatch_association(handle, paths); } HatchEditOperation::RecreateBoundary { associate, region } => { let source = self.tabs[i].scene.document.get_entity(handle).cloned(); @@ -5481,33 +5749,79 @@ impl OpenCADStudio { glam::DVec3::from_array(storage.y_axis), ); let (entities, path_groups) = if region { - let loops = source.paths.iter().map(|path| { - path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() - }).collect::>>(); - let Some(loops) = loops.filter(|loops| !loops.is_empty() && loops.iter().all(|ring| !ring.is_empty())) else { + let loops = source + .paths + .iter() + .map(|path| { + path.edges + .iter() + .map(crate::entities::hatch::edge_curve) + .collect::>>() + }) + .collect::>>(); + let Some(loops) = loops.filter(|loops| { + !loops.is_empty() + && loops.iter().all(|ring| !ring.is_empty()) + }) else { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); return Task::none(); }; - let contained = loops.iter().enumerate().map(|(inner, ring)| { - let seed = ring[0].point_at(0.0); - loops.iter().enumerate().map(|(outer, boundary)| { - inner != outer && cadkernel::geom2d::contains(boundary, seed, cadkernel::geom2d::Tolerance::new(1e-6)) - }).collect::>() - }).collect::>(); - let depths = contained.iter().map(|row| row.iter().filter(|inside| **inside).count()).collect::>(); - let groups = depths.iter().enumerate().filter(|(_, depth)| **depth % 2 == 0).map(|(outer, depth)| { - let mut indices = vec![outer]; - indices.extend((0..loops.len()).filter(|inner| depths[*inner] == *depth + 1 && contained[*inner][outer])); - indices - }).collect::>(); - let entities = groups.iter().map(|indices| { - let profiles = indices.iter().map(|index| loops[*index].clone()).collect::>(); - crate::scene::model::presspull_model::region_from_loops(&profiles, plane) - }).collect::>>(); + let contained = loops + .iter() + .enumerate() + .map(|(inner, ring)| { + let seed = ring[0].point_at(0.0); + loops + .iter() + .enumerate() + .map(|(outer, boundary)| { + inner != outer + && cadkernel::geom2d::contains( + boundary, + seed, + cadkernel::geom2d::Tolerance::new(1e-6), + ) + }) + .collect::>() + }) + .collect::>(); + let depths = contained + .iter() + .map(|row| row.iter().filter(|inside| **inside).count()) + .collect::>(); + let groups = depths + .iter() + .enumerate() + .filter(|(_, depth)| **depth % 2 == 0) + .map(|(outer, depth)| { + let mut indices = vec![outer]; + indices.extend((0..loops.len()).filter(|inner| { + depths[*inner] == *depth + 1 + && contained[*inner][outer] + })); + indices + }) + .collect::>(); + let entities = groups + .iter() + .map(|indices| { + let profiles = indices + .iter() + .map(|index| loops[*index].clone()) + .collect::>(); + crate::scene::model::presspull_model::region_from_loops( + &profiles, plane, + ) + }) + .collect::>>(); let Some(entities) = entities else { self.discard_last_undo_entry(i); - self.command_line.push_error("HATCHEDIT: boundary cannot form a region."); + self.command_line.push_error( + "HATCHEDIT: boundary cannot form a region.", + ); return Task::none(); }; (entities, groups) @@ -5528,30 +5842,39 @@ impl OpenCADStudio { ); return Task::none(); } - let groups = (0..entities.len()).map(|index| vec![index]).collect(); + let groups = + (0..entities.len()).map(|index| vec![index]).collect(); (entities, groups) }; let mut path_handles = vec![None; source.paths.len()]; for (entity, paths) in entities.into_iter().zip(path_groups) { if let Some(boundary) = self.commit_entity_handle(entity) { - for path in paths { if let Some(slot) = path_handles.get_mut(path) { *slot = Some(boundary); } } - } - } - if associate { if let Some(acadrust::EntityType::Hatch(hatch)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - for (path, boundary) in hatch.paths.iter_mut().zip(path_handles.iter().copied()) { - if let Some(boundary) = boundary { - path.boundary_handles = vec![boundary]; - path.flags.set_external(true); + for path in paths { + if let Some(slot) = path_handles.get_mut(path) { + *slot = Some(boundary); + } } } - hatch.is_associative = path_handles.iter().all(Option::is_some); - } } - self.tabs[i].scene.bump_entities(&[( - handle, - crate::scene::ChangeKind::Modified, - )]); + } + if associate { + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + for (path, boundary) in + hatch.paths.iter_mut().zip(path_handles.iter().copied()) + { + if let Some(boundary) = boundary { + path.boundary_handles = vec![boundary]; + path.flags.set_external(true); + } + } + hatch.is_associative = + path_handles.iter().all(Option::is_some); + } + } + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } } HatchEditOperation::Separate => { @@ -5577,7 +5900,7 @@ impl OpenCADStudio { } HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack - | HatchEditOperation::DrawOrderBoundary {..} + | HatchEditOperation::DrawOrderBoundary { .. } | HatchEditOperation::BeginAssociate => unreachable!(), } self.tabs[i].dirty = true; @@ -5677,10 +6000,17 @@ impl OpenCADStudio { let active = self.tabs[i].active_cmd.take(); self.undo_active_tab(); self.tabs[i].active_cmd = active; - if self.tabs[i].active_cmd.as_ref().is_some_and(|command| command.name() == "PEDIT") { - let entities: Vec<_> = self.tabs[i].scene.document.entities().cloned().collect(); + if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "PEDIT") + { + let entities: Vec<_> = + self.tabs[i].scene.document.entities().cloned().collect(); if let Some(command) = self.tabs[i].active_cmd.as_mut() { - for entity in entities { command.inject_picked_entity(entity); } + for entity in entities { + command.inject_picked_entity(entity); + } } } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -5709,8 +6039,9 @@ impl OpenCADStudio { ) }); if handles.is_empty() { - self.command_line - .push_error(crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref()); + self.command_line.push_error( + crate::t!("DIMEDIT: no editable dimensions were selected.").as_ref(), + ); } else { self.push_undo_snapshot(i, "DIMEDIT"); for handle in &handles { @@ -5806,7 +6137,8 @@ impl OpenCADStudio { } if updated { self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("DDEDIT: text updated.").as_ref()); + self.command_line + .push_output(crate::t!("DDEDIT: text updated.").as_ref()); } else { self.discard_last_undo_entry(i); self.command_line @@ -5851,11 +6183,7 @@ impl OpenCADStudio { /// this drawing doesn't already have. Each recreated record gets a fresh /// handle from the target document so it can't collide with an existing /// one. No-op for same-document pastes (the records already exist). (#129) - pub(super) fn merge_dependencies( - &mut self, - i: usize, - deps: &crate::app::ClipboardDeps, - ) { + pub(super) fn merge_dependencies(&mut self, i: usize, deps: &crate::app::ClipboardDeps) { use acadrust::TableEntry; if deps.is_empty() { return; @@ -5980,7 +6308,7 @@ impl OpenCADStudio { _ => glam::DVec3::ZERO, }; self.merge_clipboard_ext_objects(i, &by_index, annotation_delta); - // Source handles stored in the clipboard map one-to-one to the freshly + // Source handles stored in the clipboard map one-to-one to the freshly // pasted handles. Use that map to reconnect LEADER -> copied annotation. let mut handle_map = rustc_hash::FxHashMap::default(); @@ -6030,7 +6358,9 @@ impl OpenCADStudio { self.tabs[i].scene.recreate_groups(groups, &handle_map); } // Constraints wholly within the pasted selection follow it. - self.tabs[i].scene.duplicate_sketch_constraints_for(&handle_map); + self.tabs[i] + .scene + .duplicate_parametric_constraints_for(&handle_map); // Incremental: `add_entity` already tessellated every pasted top-level // solid, and existing document solids are still cached — so only newly // introduced block-definition solids need building. The full rebuild @@ -6330,16 +6660,10 @@ fn wire_segments( for model in models { for run in model.points.split(|point| !point[0].is_finite()) { for pair in run.windows(2) { - let first = glam::DVec3::new( - pair[0][0] as f64, - pair[0][1] as f64, - pair[0][2] as f64, - ); - let second = glam::DVec3::new( - pair[1][0] as f64, - pair[1][1] as f64, - pair[1][2] as f64, - ); + let first = + glam::DVec3::new(pair[0][0] as f64, pair[0][1] as f64, pair[0][2] as f64); + let second = + glam::DVec3::new(pair[1][0] as f64, pair[1][1] as f64, pair[1][2] as f64); if first.is_finite() && second.is_finite() && first.distance_squared(second) > 1.0e-18 @@ -6361,7 +6685,10 @@ fn segment_intersection_xy( [second.0.to_array(), second.1.to_array()], 0.25, )?; - Some((glam::DVec3::from_array(gap[0]), glam::DVec3::from_array(gap[1]))) + Some(( + glam::DVec3::from_array(gap[0]), + glam::DVec3::from_array(gap[1]), + )) } fn break_object_handle(document: &acadrust::CadDocument, dimension: Handle) -> Option { @@ -6383,7 +6710,10 @@ fn remove_dimension_break_data(document: &mut acadrust::CadDocument, dimension: .filter_map(|(handle, object)| match object { acadrust::objects::ObjectType::DataObject(object) => match &object.data { acadrust::objects::DataObjectData::BreakData(data) - if data.dimension_reference == dimension => Some(*handle), + if data.dimension_reference == dimension => + { + Some(*handle) + } _ => None, }, _ => None, @@ -6463,9 +6793,7 @@ fn write_dimension_break_data( document .objects .insert(object_handle, ObjectType::DataObject(object)); - if let Some(ObjectType::Dictionary(dictionary)) = - document.objects.get_mut(&dictionary_handle) - { + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) { dictionary .entries .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_BREAKDATA")); @@ -6523,8 +6851,11 @@ fn apply_dimbreak( .filter(|handle| remove_dimension_break_data(&mut scene.document, *handle)) .collect(); return DimBreakResult { - message: crate::tf!("DIMBREAK: removed breaks from {} dimension(s).", changed.len()) - .into_owned(), + message: crate::tf!( + "DIMBREAK: removed breaks from {} dimension(s).", + changed.len() + ) + .into_owned(), changed, }; } @@ -6592,11 +6923,14 @@ fn apply_dimbreak( } } let start = references.len() as i32; - references.extend(intersections.into_iter().enumerate().map( - |(index, points)| { - break_reference(start + index as i32, 1, points.0, points.1) - }, - )); + references.extend( + intersections + .into_iter() + .enumerate() + .map(|(index, points)| { + break_reference(start + index as i32, 1, points.0, points.1) + }), + ); } DimensionBreakOperation::Remove => {} } @@ -6633,16 +6967,28 @@ fn apply_dimspace( use acadrust::entities::Dimension; let (axis, normal, definition, auto_spacing) = match scene.document.get_entity(base_h) { Some(acadrust::EntityType::Dimension(dimension @ Dimension::Linear(d))) => { - let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); ( [d.rotation.cos(), d.rotation.sin(), 0.0], [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [d.definition_point.x, d.definition_point.y, d.definition_point.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], spacing, ) } Some(acadrust::EntityType::Dimension(dimension @ Dimension::Aligned(d))) => { - let spacing = dimension_auto_spacing(&scene.document, dimension, scene.creation_annotation_multiplier()); + let spacing = dimension_auto_spacing( + &scene.document, + dimension, + scene.creation_annotation_multiplier(), + ); ( [ d.second_point.x - d.first_point.x, @@ -6650,7 +6996,11 @@ fn apply_dimspace( d.second_point.z - d.first_point.z, ], [d.base.normal.x, d.base.normal.y, d.base.normal.z], - [d.definition_point.x, d.definition_point.y, d.definition_point.z], + [ + d.definition_point.x, + d.definition_point.y, + d.definition_point.z, + ], spacing, ) } @@ -6667,11 +7017,8 @@ fn apply_dimspace( let mut changed = false; if let Some(acadrust::EntityType::Dimension(dim)) = scene.document.get_entity_mut(h) { let slide = |p: &mut acadrust::types::Vector3| { - let point = cadkernel::space::move_to_dimension_spacing( - [p.x, p.y, p.z], - frame, - target, - ); + let point = + cadkernel::space::move_to_dimension_spacing([p.x, p.y, p.z], frame, target); *p = acadrust::types::Vector3::new(point[0], point[1], point[2]); }; match dim { @@ -6709,8 +7056,14 @@ fn dimension_auto_spacing( let style = document .dim_styles .iter() - .find(|style| style.name.eq_ignore_ascii_case(&dimension.base().style_name)) - .map(|style| crate::entities::dimension::resolved_dimension_style(style, dimension, document)); + .find(|style| { + style + .name + .eq_ignore_ascii_case(&dimension.base().style_name) + }) + .map(|style| { + crate::entities::dimension::resolved_dimension_style(style, dimension, document) + }); style .map(|style| { let scale = if style.dimscale > 1.0e-9 { @@ -6740,9 +7093,7 @@ fn apply_mleader_align( }); let mut changed = Vec::new(); for &handle in handles { - if let Some(acadrust::EntityType::MultiLeader(ml)) = - scene.document.get_entity_mut(handle) - { + if let Some(acadrust::EntityType::MultiLeader(ml)) = scene.document.get_entity_mut(handle) { let old = ml.context.content_base_point; let projected = closest_point(&line, [old.x, old.y]).point; let new_x = projected[0]; @@ -6779,9 +7130,7 @@ fn compatible_mleader_collect_handles( if scene.is_layer_locked(*handle) { return None; } - let acadrust::EntityType::MultiLeader(leader) = - scene.document.get_entity(*handle)? - else { + let acadrust::EntityType::MultiLeader(leader) = scene.document.get_entity(*handle)? else { return None; }; (leader.content_type == acadrust::entities::LeaderContentType::Block) @@ -6819,8 +7168,7 @@ fn apply_mleader_collect( } let px = point.x; let py = point.y; - let Some(acadrust::EntityType::MultiLeader(base)) = - scene.document.get_entity(handles[0]) + let Some(acadrust::EntityType::MultiLeader(base)) = scene.document.get_entity(handles[0]) else { return false; }; @@ -7309,21 +7657,40 @@ mod command_replacement_tests { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let tab = app.active_tab; - let handle = app.tabs[tab].scene.add_entity(acadrust::EntityType::Line( - Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)), - )); - let owner = app.tabs[tab].scene.document.get_entity(handle).unwrap().common().owner_handle; + let handle = app.tabs[tab] + .scene + .add_entity(acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 0.0, 0.0), + ))); + let owner = app.tabs[tab] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .owner_handle; - let kept = app.replace_command_entity(tab, handle, vec![acadrust::EntityType::Line( - Line::from_points(Vector3::ZERO, Vector3::new(3.0, 0.0, 0.0)), - )]); + let kept = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(3.0, 0.0, 0.0), + ))], + ); assert_eq!(kept, vec![handle]); let edited = app.tabs[tab].scene.document.get_entity(handle).unwrap(); assert_eq!(edited.common().owner_handle, owner); assert!(matches!(edited, acadrust::EntityType::Line(line) if line.end.x == 3.0)); - let allocated = app.replace_command_entity(tab, handle, - vec![acadrust::EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0))]); + let allocated = app.replace_command_entity( + tab, + handle, + vec![acadrust::EntityType::Circle(Circle::from_coords( + 0.0, 0.0, 0.0, 2.0, + ))], + ); assert_eq!(allocated.len(), 1); assert_ne!(allocated[0], handle); assert!(app.tabs[tab].scene.document.get_entity(handle).is_none()); @@ -7331,22 +7698,26 @@ mod command_replacement_tests { } #[cfg(test)] -mod sketch_constraint_undo_tests { +mod parametric_constraint_undo_tests { use super::*; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { app.tabs[app.active_tab] .scene - .add_entity(acadrust::EntityType::Line(acadrust::entities::Line::from_points( - acadrust::types::Vector3::new(x1, y1, 0.0), - acadrust::types::Vector3::new(x2, y2, 0.0), - ))) + .add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(x1, y1, 0.0), + acadrust::types::Vector3::new(x2, y2, 0.0), + ), + )) } fn line_angle_deg(app: &OpenCADStudio, handle: Handle) -> f64 { match app.tabs[app.active_tab].scene.document.get_entity(handle) { - Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y).atan2(l.end.x - l.start.x).to_degrees(), + Some(acadrust::EntityType::Line(l)) => (l.end.y - l.start.y) + .atan2(l.end.x - l.start.x) + .to_degrees(), other => panic!("expected a Line, got {other:?}"), } } @@ -7360,15 +7731,23 @@ mod sketch_constraint_undo_tests { let original_angle = line_angle_deg(&app, line); assert!((original_angle - 16.699244).abs() < 1e-3); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "constraint should have leveled the line"); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "constraint should have leveled the line" + ); assert_eq!( - app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), 1 ); @@ -7376,7 +7755,7 @@ mod sketch_constraint_undo_tests { assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7388,15 +7767,23 @@ mod sketch_constraint_undo_tests { ); app.redo_steps(1); - assert!(line_angle_deg(&app, line).abs() < 1e-6, "redo should re-level the line"); + assert!( + line_angle_deg(&app, line).abs() < 1e-6, + "redo should re-level the line" + ); assert_eq!( - app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap().constraints.len(), + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), 1, "redo should restore the constraint record" ); } - /// Drawing commands must not create sketch constraints implicitly. + /// Drawing commands must not create parametric constraints implicitly. #[test] fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { let mut app = OpenCADStudio::new_for_test(); @@ -7407,13 +7794,15 @@ mod sketch_constraint_undo_tests { acadrust::types::Vector3::new(5.0, 0.0, 0.0), // exactly on `existing`'s end point acadrust::types::Vector3::new(10.0, 0.0, 0.0), )); - app.tabs[app.active_tab].active_cmd = Some(Box::new(crate::modules::draw::draw::line::LineCommand::new())); + app.tabs[app.active_tab].active_cmd = Some(Box::new( + crate::modules::draw::draw::line::LineCommand::new(), + )); let _ = app.apply_cmd_result(CmdResult::CommitEntity(new_line)); assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7423,36 +7812,59 @@ mod sketch_constraint_undo_tests { /// Conflict resolution removes one flagged constraint and is undoable. #[test] - fn resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable() { + fn resolve_one_parametric_conflict_removes_a_flagged_constraint_and_is_undoable() { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 3.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Horizontal, - refs: vec![SketchRef::whole(line)], + refs: vec![ParametricRef::whole(line)], driving_param: None, label: "Horizontal constraint", }); // exact duplicate — flagged redundant once bumped - app.tabs[app.active_tab].scene.bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); + app.tabs[app.active_tab] + .scene + .bump_entities(&[(line, crate::scene::ChangeKind::Modified)]); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); assert_eq!(set.constraints.len(), 2); - assert_eq!(set.conflicts.len(), 1, "the duplicate should have been flagged"); + assert_eq!( + set.conflicts.len(), + 1, + "the duplicate should have been flagged" + ); - app.resolve_one_sketch_conflict(); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); - assert_eq!(set.constraints.len(), 1, "one of the two duplicates should have been removed"); + app.resolve_one_parametric_conflict(); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 1, + "one of the two duplicates should have been removed" + ); app.undo_steps(1); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).unwrap(); - assert_eq!(set.constraints.len(), 2, "undo should restore the removed constraint"); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + assert_eq!( + set.constraints.len(), + 2, + "undo should restore the removed constraint" + ); } /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's @@ -7468,24 +7880,38 @@ mod sketch_constraint_undo_tests { // The constraint is added *before* the parameter exists -- allowed, // same as `ParameterTable::set`'s own forward-reference tolerance; // it just doesn't resolve to anything until the parameter is defined. - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, - refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("target_len".to_string())), + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "target_len".to_string(), + )), label: "Distance constraint", }); app.named_parameter_editor_rows = - vec![crate::ui::window::named_parameters::ParamEditorRow { name: "target_len".to_string(), formula: "8".to_string() }]; + vec![crate::ui::window::named_parameters::ParamEditorRow { + name: "target_len".to_string(), + formula: "8".to_string(), + }]; app.apply_named_parameter_editor_rows(); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(8.0) + ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(acadrust::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 8.0).abs() < 1e-6, "length should track the applied parameter, got {len}"); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the applied parameter, got {len}" + ); // Redefining the parameter and re-applying should ripple again. app.named_parameter_editor_rows[0].formula = "3".to_string(); @@ -7495,10 +7921,19 @@ mod sketch_constraint_undo_tests { other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 3.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + assert!( + (len - 3.0).abs() < 1e-6, + "length should track the redefined parameter, got {len}" + ); app.undo_steps(1); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(8.0)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(8.0) + ); let length_after_undo = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(acadrust::EntityType::Line(line)) => line.length(), other => panic!("expected a Line, got {other:?}"), @@ -7506,7 +7941,13 @@ mod sketch_constraint_undo_tests { assert!((length_after_undo - 8.0).abs() < 1e-6); app.redo_steps(1); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("target_len"), Ok(3.0)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("target_len"), + Ok(3.0) + ); } /// End-to-end through the actual `Message` handlers a Properties-panel @@ -7522,41 +7963,89 @@ mod sketch_constraint_undo_tests { let mut app = OpenCADStudio::new_for_test(); let _ = app.automation_op(r#"{"op":"new"}"#); let line = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Distance, - refs: vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - driving_param: Some(crate::scene::named_parameters::DrivingValue::Named("len".to_string())), + refs: vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + driving_param: Some(crate::scene::named_parameters::DrivingValue::Named( + "len".to_string(), + )), label: "Distance constraint", }); // Add: the first row is auto-named "param1". let _ = app.update(Message::PropParamAddNew); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1")); - let index = app.tabs[app.active_tab].scene.named_parameters().iter().position(|p| p.name == "param1").unwrap(); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + let index = app.tabs[app.active_tab] + .scene + .named_parameters() + .iter() + .position(|p| p.name == "param1") + .unwrap(); // Rename param1 -> len (commit-on-submit, not per keystroke: input // alone must not touch the table yet). - let _ = app.update(Message::PropParamInput { index, field: ParamField::Name, value: "len".to_string() }); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("param1"), "typing alone must not commit"); - let _ = app.update(Message::PropParamCommit { index, field: ParamField::Name }); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("param1")); - assert!(app.tabs[app.active_tab].scene.named_parameters().contains("len")); + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Name, + value: "len".to_string(), + }); + assert!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1"), + "typing alone must not commit" + ); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Name, + }); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("param1")); + assert!(app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); // Redefine its formula to 8 and confirm the Distance constraint // (already referencing "len" by name, defined before this) re-solves. - let _ = app.update(Message::PropParamInput { index, field: ParamField::Formula, value: "8".to_string() }); - let _ = app.update(Message::PropParamCommit { index, field: ParamField::Formula }); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("len"), Ok(8.0)); + let _ = app.update(Message::PropParamInput { + index, + field: ParamField::Formula, + value: "8".to_string(), + }); + let _ = app.update(Message::PropParamCommit { + index, + field: ParamField::Formula, + }); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("len"), + Ok(8.0) + ); let (start, end) = match app.tabs[app.active_tab].scene.document.get_entity(line) { Some(acadrust::EntityType::Line(l)) => (l.start, l.end), other => panic!("expected a Line, got {other:?}"), }; let len = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt(); - assert!((len - 8.0).abs() < 1e-6, "length should track the redefined parameter, got {len}"); + assert!( + (len - 8.0).abs() < 1e-6, + "length should track the redefined parameter, got {len}" + ); // Delete: the row is gone from the table. let _ = app.update(Message::PropParamDelete(index)); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("len")); + assert!(!app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("len")); } /// A Constraints-section row click selects every entity the constraint @@ -7568,18 +8057,24 @@ mod sketch_constraint_undo_tests { let a = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut app, 0.0, 5.0, 10.0, 5.0); let unrelated = add_line(&mut app, 20.0, 20.0, 30.0, 20.0); - let _ = app.apply_cmd_result(CmdResult::AddSketchConstraint { + let _ = app.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Parallel, - refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], driving_param: None, label: "Parallel constraint", }); - app.tabs[app.active_tab].scene.select_entity(unrelated, true); + app.tabs[app.active_tab] + .scene + .select_entity(unrelated, true); let _ = app.update(Message::PropConstraintLinkClick(vec![a, b])); - let selected: std::collections::HashSet = - app.tabs[app.active_tab].scene.selected_entities().into_iter().map(|(h, _)| h).collect(); + let selected: std::collections::HashSet = app.tabs[app.active_tab] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); assert_eq!(selected, std::collections::HashSet::from([a, b])); } @@ -7594,15 +8089,36 @@ mod sketch_constraint_undo_tests { let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { name: "good".to_string(), formula: "10".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "bad".to_string(), formula: "1 +".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "good".to_string(), + formula: "10".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "bad".to_string(), + formula: "1 +".to_string(), + }, ]; app.apply_named_parameter_editor_rows(); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("good"), Ok(10.0)); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("bad"), "a malformed row must not be added"); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("good"), + Ok(10.0) + ); assert!( - app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error), + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("bad"), + "a malformed row must not be added" + ); + assert!( + app.command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error), "a malformed row must be reported as an error, not silently dropped" ); } @@ -7616,15 +8132,40 @@ mod sketch_constraint_undo_tests { let _ = app.automation_op(r#"{"op":"new"}"#); app.named_parameter_editor_rows = vec![ - crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "1".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "x".to_string(), formula: "2".to_string() }, - crate::ui::window::named_parameters::ParamEditorRow { name: "y".to_string(), formula: "3".to_string() }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "1".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "x".to_string(), + formula: "2".to_string(), + }, + crate::ui::window::named_parameters::ParamEditorRow { + name: "y".to_string(), + formula: "3".to_string(), + }, ]; app.apply_named_parameter_editor_rows(); - assert!(!app.tabs[app.active_tab].scene.named_parameters().contains("x"), "a duplicated name must not be added at all"); - assert_eq!(app.tabs[app.active_tab].scene.named_parameters().resolve("y"), Ok(3.0)); - assert!(app.command_line.history.iter().any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); + assert!( + !app.tabs[app.active_tab] + .scene + .named_parameters() + .contains("x"), + "a duplicated name must not be added at all" + ); + assert_eq!( + app.tabs[app.active_tab] + .scene + .named_parameters() + .resolve("y"), + Ok(3.0) + ); + assert!(app + .command_line + .history + .iter() + .any(|e| e.kind == crate::ui::command_line::EntryKind::Error)); } /// Two endpoint picks resolve to a Coincident constraint. @@ -7641,12 +8182,18 @@ mod sketch_constraint_undo_tests { label: "Coincident constraint", }); - let set = app.tabs[app.active_tab].scene.sketch_constraint_set(SketchScope::ModelSpace).expect("a constraint set should exist"); + let set = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("a constraint set should exist"); assert_eq!(set.constraints.len(), 1); let c = &set.constraints[0]; assert_eq!(c.kind, ConstraintKind::Coincident); let entities: Vec = c.refs.iter().map(|r| r.entity).collect(); - assert!(entities.contains(&a) && entities.contains(&b), "the constraint should reference both lines"); + assert!( + entities.contains(&a) && entities.contains(&b), + "the constraint should reference both lines" + ); } /// A pick that doesn't land near any real point (no object snap match) @@ -7667,7 +8214,7 @@ mod sketch_constraint_undo_tests { assert_eq!( app.tabs[app.active_tab] .scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map(|s| s.constraints.len()) .unwrap_or(0), 0, @@ -7690,12 +8237,13 @@ mod delobj_tests { let profile = app.tabs[i] .scene .add_entity(acadrust::EntityType::Circle(circle)); - let path = app.tabs[i].scene.add_entity(acadrust::EntityType::Line( - acadrust::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(0.0, 0.0, 5.0), - ), - )); + let path = + app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Line(acadrust::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 5.0), + ))); app.delete_objects = value; let _ = app.apply_cmd_result(CmdResult::SweepEntities { @@ -7706,14 +8254,18 @@ mod delobj_tests { color: [1.0; 4], }); - let created = app.tabs[i].scene.document.entities().any(|entity| match mode { - crate::command::ExtrudeMode::Solid => { - matches!(entity, acadrust::EntityType::Solid3D(_)) - } - crate::command::ExtrudeMode::Surface => { - matches!(entity, acadrust::EntityType::Surface(_)) - } - }); + let created = app.tabs[i] + .scene + .document + .entities() + .any(|entity| match mode { + crate::command::ExtrudeMode::Solid => { + matches!(entity, acadrust::EntityType::Solid3D(_)) + } + crate::command::ExtrudeMode::Surface => { + matches!(entity, acadrust::EntityType::Surface(_)) + } + }); assert!(created, "SWEEP must produce the requested result"); ( app.tabs[i].scene.document.get_entity(profile).is_some(), @@ -7752,41 +8304,93 @@ mod thicken_tests { fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); - let body = cadkernel::brep::planar_region(Plane::XY, &[ - vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })], - ]).unwrap(); - let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body); + let body = cadkernel::brep::planar_region( + Plane::XY, + &[vec![Curve::Circle(Circle { + centre: [0.0; 2], + radius: 3.0, + })]], + ) + .unwrap(); + let source = app.add_surface_model( + acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), + body, + ); assert!(!source.is_null()); let i = app.active_tab; - let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic))); - let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); + let invalid = app.tabs[i] + .scene + .add_entity(acadrust::EntityType::Surface(Surface::new( + SurfaceKind::Generic, + ))); + let original = + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); let before = app.tabs[i].history.undo_stack.len(); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 }); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![source], + distance: 0.0, + }); assert_eq!(app.tabs[i].history.undo_stack.len(), before); - let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 }); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![invalid, source], + distance: 2.0, + }); assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); - let result = app.tabs[i].scene.document.entities().find_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }).unwrap(); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); - assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); - let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume; - let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap(); + let result = app.tabs[i] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }) + .unwrap(); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); + let expected = + cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]) + .unwrap() + .volume; + let bytes = crate::io::save_to_bytes( + &app.tabs[i].scene.document, + "dwg", + app.tabs[i].scene.document.version, + ) + .unwrap(); let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); let mut restored = crate::scene::Scene::new(); restored.document = document; restored.restore_solid_models(&[result]); - let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume; + let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]) + .unwrap() + .volume; assert!((expected - actual).abs() < 1e-8); app.undo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_none()); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none()); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_none()); assert!(app.tabs[i].scene.document.get_entity(source).is_some()); assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); app.redo_active_tab(); assert!(app.tabs[i].scene.document.get_entity(result).is_some()); - assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); - assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(result) + .is_some()); + assert_eq!( + serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), + original + ); } #[test] @@ -7800,12 +8404,9 @@ mod thicken_tests { Parameterization::Chord, ) .unwrap(); - let body = cadkernel::brep::extrude_surface( - Plane::XY, - &[Curve::Nurbs(profile)], - [0.0, 0.0, 2.0], - ) - .unwrap(); + let body = + cadkernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) + .unwrap(); let source = app.add_surface_model( acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)), body, @@ -7822,8 +8423,7 @@ mod thicken_tests { .document .entities() .filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)) - .then_some(entity.common().handle) + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) }) .collect::>(); assert_eq!(solids.len(), 1); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index dc59babfa..e45c8c1a4 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -29,12 +29,7 @@ impl OpenCADStudio { _ => None, }) .collect(); - let cmd_obj = MlineCommand::with_styles( - styles, - style_name, - scale, - justification, - ); + let cmd_obj = MlineCommand::with_styles(styles, style_name, scale, justification); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } @@ -133,25 +128,29 @@ impl OpenCADStudio { if rest.is_empty() { // List attributes. if ins.attributes.is_empty() { - self.command_line.push_output(crate::tf!( - " Insert {:x}: no attributes.", - sh.value() - ).as_ref()); + self.command_line.push_output( + crate::tf!(" Insert {:x}: no attributes.", sh.value()) + .as_ref(), + ); } else { for attr in &ins.attributes { - self.command_line.push_output(crate::tf!( - " [{tag}] = {val}", - tag = attr.tag, - val = attr.get_value() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + " [{tag}] = {val}", + tag = attr.tag, + val = attr.get_value() + ) + .as_ref(), + ); } } } } } if !found_any { - self.command_line - .push_error(crate::t!("ATTEDIT: no Insert entities in selection.").as_ref()); + self.command_line.push_error( + crate::t!("ATTEDIT: no Insert entities in selection.").as_ref(), + ); } // If tag + value supplied, mutate attributes. if parts.len() == 2 && !parts[0].is_empty() { @@ -179,13 +178,17 @@ impl OpenCADStudio { } if changed > 0 { self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( + self.command_line.push_output( + crate::tf!( "ATTEDIT: updated {changed} attribute(s) [{tag_up}] = {new_val}." - ).as_ref()); + ) + .as_ref(), + ); } else { - self.command_line.push_error(crate::tf!( - "ATTEDIT: tag '{tag_up}' not found in selection." - ).as_ref()); + self.command_line.push_error( + crate::tf!("ATTEDIT: tag '{tag_up}' not found in selection.") + .as_ref(), + ); } } } @@ -243,9 +246,10 @@ impl OpenCADStudio { } } self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "ATTDISP {sub}: {count} attribute definition(s) updated." - ).as_ref()); + self.command_line.push_output( + crate::tf!("ATTDISP {sub}: {count} attribute definition(s) updated.") + .as_ref(), + ); } _ => { self.command_line @@ -388,8 +392,10 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } None => { - self.command_line - .push_info(crate::t!("ARC Continue No previous line or arc to continue.").as_ref()); + self.command_line.push_info( + crate::t!("ARC Continue No previous line or arc to continue.") + .as_ref(), + ); } } } @@ -528,16 +534,26 @@ impl OpenCADStudio { if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERRESET: {count} center object(s) updated.")); + self.command_line.push_output(&crate::tf!( + "CENTERRESET: {count} center object(s) updated." + )); } "CENTERREASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); - let mark_targets: Vec<_> = handles.iter().copied().filter(|handle| { - let Some(acadrust::EntityType::Line(line)) = self.tabs[i].scene.document.get_entity(*handle) else { return false; }; - acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data).is_some() - }).collect(); + let mark_targets: Vec<_> = handles + .iter() + .copied() + .filter(|handle| { + let Some(acadrust::EntityType::Line(line)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + return false; + }; + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + .is_some() + }) + .collect(); if mark_targets.len() == 1 && handles.len() == 1 { use crate::modules::draw::draw::dimcenter::CenterMarkReassociateCommand; let new_cmd = CenterMarkReassociateCommand::new(mark_targets[0]); @@ -546,25 +562,35 @@ impl OpenCADStudio { return Some(self.finish_dispatch(cmd)); } self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, true) - + self.tabs[i].scene.set_center_mark_association(&handles, true); + let count = self.tabs[i] + .scene + .set_centerline_association(&handles, true) + + self.tabs[i] + .scene + .set_center_mark_association(&handles, true); if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERREASSOCIATE: {count} center object(s) associated.")); + self.command_line.push_output(&crate::tf!( + "CENTERREASSOCIATE: {count} center object(s) associated." + )); } "CENTERDISASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); self.push_undo_snapshot(i, "CENTERDISASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, false) - + self.tabs[i].scene.set_center_mark_association(&handles, false); + let count = self.tabs[i] + .scene + .set_centerline_association(&handles, false) + + self.tabs[i] + .scene + .set_center_mark_association(&handles, false); if count > 0 { self.tabs[i].dirty = true; } - self.command_line - .push_output(&crate::tf!("CENTERDISASSOCIATE: {count} center object(s) detached.")); + self.command_line.push_output(&crate::tf!( + "CENTERDISASSOCIATE: {count} center object(s) detached." + )); } "DIMCENTER" => { @@ -602,15 +628,20 @@ impl OpenCADStudio { self.command_line.push_info(&selection.prompt()); self.tabs[i].active_cmd = Some(Box::new(selection)); } else { - let replacements = self.tabs[i].scene.selected_entities().into_iter() + let replacements = self.tabs[i] + .scene + .selected_entities() + .into_iter() .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) - .filter_map(|(handle, entity)| ReverseCommand::reversed(entity) - .map(|reversed| (handle, vec![reversed]))) + .filter_map(|(handle, entity)| { + ReverseCommand::reversed(entity) + .map(|reversed| (handle, vec![reversed])) + }) .collect::>(); if !replacements.is_empty() { - return Some(self.apply_cmd_result(crate::command::CmdResult::ReplaceMany( - replacements, Vec::new(), - ))); + return Some(self.apply_cmd_result( + crate::command::CmdResult::ReplaceMany(replacements, Vec::new()), + )); } } } @@ -766,9 +797,7 @@ impl OpenCADStudio { glam::DVec3::from_array(storage.x_axis), glam::DVec3::from_array(storage.y_axis), ); - let boundary_sources = self.tabs[i] - .scene - .boundary_sources_on_plane(plane, 1.0e-6); + let boundary_sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let selected = self.tabs[i] .scene @@ -776,20 +805,14 @@ impl OpenCADStudio { .into_iter() .map(|(handle, _)| handle) .collect::>(); - let inherited = selected - .iter() - .find_map(|handle| { - let model = self.tabs[i].scene.hatches.get(handle)?.clone(); - let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); - Some((model, common.color.clone(), common.transparency)) - }); - let new_cmd = HatchCommand::new( - outlines, - boundary_sources, - selected, - inherited, - plane, - ).with_origin(self.tabs[i].scene.document.hatch_origin()); + let inherited = selected.iter().find_map(|handle| { + let model = self.tabs[i].scene.hatches.get(handle)?.clone(); + let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); + Some((model, common.color.clone(), common.transparency)) + }); + let new_cmd = + HatchCommand::new(outlines, boundary_sources, selected, inherited, plane) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.refresh_area_preview(i); @@ -804,11 +827,11 @@ impl OpenCADStudio { if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() { let entity = self.tabs[i].scene.document.get_entity(h); let annotative = entity.is_some_and(|entity| { - crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - entity, - ) - }); + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); let (scale, angle) = match entity { Some(acadrust::EntityType::Hatch(hatch)) => ( hatch.pattern_scale as f32, @@ -822,15 +845,28 @@ impl OpenCADStudio { scale, angle, annotative, - ).with_appearance(entity,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); + ) + .with_appearance( + entity, + self.tabs[i].scene.document.header.current_entity_color, + self.tabs[i].scene.document.current_entity_transparency(), + ) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - self.command_line - .push_error(crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref()); + self.command_line.push_error( + crate::t!("HATCHEDIT: selected entity is not a hatch.").as_ref(), + ); } } else { - let cmd = HatcheditCommand::new().with_appearance(None,self.tabs[i].scene.document.header.current_entity_color,self.tabs[i].scene.document.current_entity_transparency()).with_origin(self.tabs[i].scene.document.hatch_origin()); + let cmd = HatcheditCommand::new() + .with_appearance( + None, + self.tabs[i].scene.document.header.current_entity_color, + self.tabs[i].scene.document.current_entity_transparency(), + ) + .with_origin(self.tabs[i].scene.document.hatch_origin()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -854,9 +890,7 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let sources = self.tabs[i] - .scene - .boundary_sources_on_plane(plane, 1.0e-6); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); let selected = self.tabs[i] .scene .selected_entities() @@ -895,7 +929,8 @@ impl OpenCADStudio { SplineCommand::control_vertices() } else { SplineCommand::new() - }.with_document(&self.tabs[i].scene.document); + } + .with_document(&self.tabs[i].scene.document); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -935,8 +970,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { use crate::modules::draw::modify::mirror::MirrorCommand; - let (wires, text_ghosts) = - self.tabs[i].scene.mirror_preview_parts(&handles); + let (wires, text_ghosts) = self.tabs[i].scene.mirror_preview_parts(&handles); let mirror_text = self.tabs[i].scene.document.header.mirror_text; let new_cmd = MirrorCommand::new(handles, wires, text_ghosts, mirror_text); self.command_line.push_info(&new_cmd.prompt()); @@ -986,10 +1020,10 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let dimensional = cmd.starts_with("DC"); let visible = !matches!(cmd, "GCHIDE" | "DCHIDE"); - let count = self.tabs[i].scene.set_sketch_constraint_visibility( + let count = self.tabs[i].scene.set_parametric_constraint_visibility( scope, Some(&handles), dimensional, @@ -1002,19 +1036,19 @@ impl OpenCADStudio { } "GCRESET" => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let count = self.tabs[i] .scene - .set_sketch_constraint_visibility(scope, None, false, true); + .set_parametric_constraint_visibility(scope, None, false, true); self.command_line .push_output(format!("{} constraint indicator(s) reset.", count).as_str()); } "GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let dimensional = cmd.starts_with("DC"); let visible = matches!(cmd, "GCSHOWALL" | "DCSHOWALL"); - let count = self.tabs[i].scene.set_sketch_constraint_visibility( + let count = self.tabs[i].scene.set_parametric_constraint_visibility( scope, None, dimensional, @@ -1032,11 +1066,8 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); - let Some(before) = self.tabs[i] - .scene - .sketch_constraint_set(scope) - .cloned() + let scope = self.tabs[i].current_parametric_scope(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() else { self.command_line.push_output("No constraints found."); return None; @@ -1061,8 +1092,8 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "Delete constraints", handles.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); - let set = self.tabs[i].scene.sketch_constraint_set_mut(scope); + .record_undo_parametric_constraints_before(scope, before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); for id in &ids { set.remove(*id); } @@ -1090,10 +1121,10 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let inferred = self.tabs[i] .scene - .inferred_sketch_constraints(scope, &handles); + .inferred_parametric_constraints(scope, &handles); if inferred.is_empty() { self.command_line .push_output("No supported geometric relations were found."); @@ -1101,20 +1132,22 @@ impl OpenCADStudio { } let before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) }); let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); + .record_undo_parametric_constraints_before(scope, before); let count = inferred.len(); for (kind, refs) in inferred { self.tabs[i] .scene - .sketch_constraint_set_mut(scope) + .parametric_constraint_set_mut(scope) .add(kind, refs, None); } let changes: Vec<_> = handles @@ -1142,14 +1175,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else { - self.command_line.push_output( - "Select one open spline and one open target curve.", - ); + self.command_line + .push_output("Select one open spline and one open target curve."); return None; }; use crate::command::CmdResult; - use crate::scene::sketch_constraints::ConstraintKind; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::ConstraintKind; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Smooth, refs, driving_param: None, @@ -1166,7 +1198,7 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let conversions: Vec<_> = handles .iter() .copied() @@ -1179,23 +1211,24 @@ impl OpenCADStudio { )?; self.tabs[i] .scene - .validate_sketch_constraint(kind, &refs, Some(&value)) + .validate_parametric_constraint(kind, &refs, Some(&value)) .ok()?; Some((handle, kind, refs, value)) }) .collect(); if conversions.is_empty() { - self.command_line.push_output( - "No supported associative dimensions were selected.", - ); + self.command_line + .push_output("No supported associative dimensions were selected."); return None; } let before = self.tabs[i] .scene - .sketch_constraint_set(scope) + .parametric_constraint_set(scope) .cloned() .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) }); let mut touched: Vec<_> = conversions .iter() @@ -1215,12 +1248,13 @@ impl OpenCADStudio { ); self.tabs[i] .scene - .record_undo_sketch_constraints_before(scope, before); + .record_undo_parametric_constraints_before(scope, before); for (_, kind, refs, value) in conversions { - self.tabs[i] - .scene - .sketch_constraint_set_mut(scope) - .add(kind, refs, Some(value)); + self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + Some(value), + ); } self.tabs[i].scene.erase_entities(&dimensions); let changes: Vec<_> = touched @@ -1251,15 +1285,15 @@ impl OpenCADStudio { .push_output("Select exactly one entity, then run this constraint again."); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), _ => (ConstraintKind::Fixed, "Fixed constraint"), }; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, - refs: vec![SketchRef::whole(handles[0])], + refs: vec![ParametricRef::whole(handles[0])], driving_param: None, label, })); @@ -1280,7 +1314,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { + "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" + | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1293,18 +1328,23 @@ impl OpenCADStudio { ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), - "QCONSTRAINT" => (ConstraintKind::Perpendicular, "Perpendicular constraint"), + "QCONSTRAINT" => { + (ConstraintKind::Perpendicular, "Perpendicular constraint") + } "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), _ => (ConstraintKind::Equal, "Equal constraint"), }; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, - refs: vec![SketchRef::whole(handles[0]), SketchRef::whole(handles[1])], + refs: vec![ + ParametricRef::whole(handles[0]), + ParametricRef::whole(handles[1]), + ], driving_param: None, label, })); @@ -1319,13 +1359,18 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else if handles.len() != 2 { - self.command_line.push_output("Select exactly two circles/arcs, then run this constraint again."); + self.command_line.push_output( + "Select exactly two circles/arcs, then run this constraint again.", + ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Concentric, - refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1])], + refs: vec![ + ParametricRef::center(handles[0]), + ParametricRef::center(handles[1]), + ], driving_param: None, label: "Concentric constraint", })); @@ -1345,10 +1390,14 @@ impl OpenCADStudio { ); } else { use crate::command::CmdResult; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; - return Some(self.apply_cmd_result(CmdResult::AddSketchConstraint { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind: ConstraintKind::Symmetric, - refs: vec![SketchRef::center(handles[0]), SketchRef::center(handles[1]), SketchRef::whole(handles[2])], + refs: vec![ + ParametricRef::center(handles[0]), + ParametricRef::center(handles[1]), + ParametricRef::whole(handles[2]), + ], driving_param: None, label: "Symmetric constraint", })); @@ -1368,13 +1417,26 @@ impl OpenCADStudio { ); } else { use crate::modules::parametric::PointOnEntityConstraintCommand; - use crate::scene::sketch_constraints::ConstraintKind; + use crate::scene::parametric_constraints::ConstraintKind; let (name, kind, label) = match cmd { - "CPCONSTRAINT" => ("CPCONSTRAINT", ConstraintKind::CenterPoint, "Center point constraint"), - "MPCONSTRAINT" => ("MPCONSTRAINT", ConstraintKind::Midpoint, "Midpoint constraint"), - _ => ("OCCONSTRAINT", ConstraintKind::PointOnCurve, "Point on curve constraint"), + "CPCONSTRAINT" => ( + "CPCONSTRAINT", + ConstraintKind::CenterPoint, + "Center point constraint", + ), + "MPCONSTRAINT" => ( + "MPCONSTRAINT", + ConstraintKind::Midpoint, + "Midpoint constraint", + ), + _ => ( + "OCCONSTRAINT", + ConstraintKind::PointOnCurve, + "Point on curve constraint", + ), }; - let new_cmd = PointOnEntityConstraintCommand::new(name, kind, handles[0], label); + let new_cmd = + PointOnEntityConstraintCommand::new(name, kind, handles[0], label); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1389,8 +1451,9 @@ impl OpenCADStudio { self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else if handles.len() != 1 { - self.command_line - .push_output("Select exactly one line or circle, then run this constraint again."); + self.command_line.push_output( + "Select exactly one line or circle, then run this constraint again.", + ); } else { use crate::modules::parametric::{DistanceConstraintCommand, DistanceMode}; let (command_name, mode) = match cmd { @@ -1412,7 +1475,9 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - None => self.command_line.push_output("Select a line or a circle for a distance constraint."), + None => self + .command_line + .push_output("Select a line or a circle for a distance constraint."), } } } @@ -1445,7 +1510,9 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - None => self.command_line.push_output("Select two lines for an angle constraint."), + None => self + .command_line + .push_output("Select two lines for an angle constraint."), } } } @@ -1457,8 +1524,7 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" - | "TORUS" => { + "BOX" | "WEDGE" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" | "TORUS" => { use crate::modules::model::primitive_cmd::PrimitiveCommand; let new_cmd = PrimitiveCommand::new(cmd); self.command_line.push_info(&new_cmd.prompt()); @@ -1625,54 +1691,69 @@ impl OpenCADStudio { continue; }; let mut region = Region::new(); - region.point_of_reference = Vector3::new( - plane.origin[0], - plane.origin[1], - plane.origin[2], - ); + region.point_of_reference = + Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); region.common.layer = self.tabs[i].active_layer.clone(); regions.push((region, body)); sources.push(*handle); } } // Open inputs must share one geometric plane, independently of the UCS. - let open_curves: Vec<_> = self.tabs[i].scene.selected_entities().iter() + let open_curves: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .iter() .filter(|(handle, _)| !sources.contains(handle)) .filter_map(|(_, entity)| crate::entities::curve::entity_curve(entity)) .collect(); let open_plane = cadkernel::space::common_curve_plane(&open_curves, 1.0e-6); let open_plane_rejected = !open_curves.is_empty() && open_plane.is_none(); if let Some(plane) = open_plane { - let working_plane = crate::command::WorkingPlane::new( - glam::DVec3::from_array(plane.origin), - glam::DVec3::from_array(plane.x_axis), - glam::DVec3::from_array(plane.y_axis), - ); - let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i].scene - .selected_entities().iter().map(|(handle, _)| *handle).collect(); - let mut boundary_sources = self.tabs[i].scene - .boundary_sources_on_plane(working_plane, 1.0e-6); - boundary_sources.retain(|handle, _| { - selected_handles.contains(handle) && !sources.contains(handle) - }); - for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { - let paths = crate::scene::exact_hatch_paths( - std::slice::from_ref(&ring), &[true], &boundary_sources, 1.0e-6, + let working_plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(plane.origin), + glam::DVec3::from_array(plane.x_axis), + glam::DVec3::from_array(plane.y_axis), ); - let Some(path) = paths.first() else { continue; }; - let Some(curves) = path.edges.iter() - .map(crate::entities::hatch::edge_curve).collect::>>() - else { continue; }; - let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) - else { continue; }; - let mut region = Region::new(); - region.point_of_reference = Vector3::new( - plane.origin[0], plane.origin[1], plane.origin[2], - ); - region.common.layer = self.tabs[i].active_layer.clone(); - regions.push((region, body)); - sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); - } + let selected_handles: rustc_hash::FxHashSet<_> = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .collect(); + let mut boundary_sources = self.tabs[i] + .scene + .boundary_sources_on_plane(working_plane, 1.0e-6); + boundary_sources.retain(|handle, _| { + selected_handles.contains(handle) && !sources.contains(handle) + }); + for ring in crate::scene::boundary_faces(&boundary_sources, 1.0e-6) { + let paths = crate::scene::exact_hatch_paths( + std::slice::from_ref(&ring), + &[true], + &boundary_sources, + 1.0e-6, + ); + let Some(path) = paths.first() else { + continue; + }; + let Some(curves) = path + .edges + .iter() + .map(crate::entities::hatch::edge_curve) + .collect::>>() + else { + continue; + }; + let Some(body) = cadkernel::brep::planar_region(plane, &[curves]) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = + Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); + sources.extend(crate::scene::ring_source_handles(&ring, &boundary_sources)); + } } else if open_plane_rejected { self.command_line.push_error( "REGION: open objects must form coplanar, noncollinear boundaries.", @@ -1720,8 +1801,10 @@ impl OpenCADStudio { let sides = nums.get(2).map(|s| *s as usize).unwrap_or(4); return Some(self.solid_pyramid(nums[0], nums[1], sides)); } - self.command_line - .push_info(crate::t!("Usage: PYRAMID [sides] (default 4 sides)").as_ref()); + self.command_line.push_info( + crate::t!("Usage: PYRAMID [sides] (default 4 sides)") + .as_ref(), + ); } // SECTION [X|Y|Z] — draw the cross-section outline of the solid. @@ -1756,7 +1839,10 @@ impl OpenCADStudio { match parts.get(val_idx).and_then(|s| s.parse::().ok()) { Some(v) => return Some(self.solid_section(axis, v)), None => self.command_line.push_info( - crate::t!("Usage: SECTION [X|Y|Z] (cross-sections the selected solid)").as_ref(), + crate::t!( + "Usage: SECTION [X|Y|Z] (cross-sections the selected solid)" + ) + .as_ref(), ), } } @@ -1909,7 +1995,8 @@ impl OpenCADStudio { match angle { Some(a) => return Some(self.solid_rotate3d(axis, a)), None => self.command_line.push_info( - crate::t!("Usage: 3DROTATE [X|Y|Z] (rotates the selected solid)").as_ref(), + crate::t!("Usage: 3DROTATE [X|Y|Z] (rotates the selected solid)") + .as_ref(), ), } } @@ -1951,11 +2038,12 @@ impl OpenCADStudio { } }) }); - let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { - let min = glam::DVec3::from_array(min); - let max = glam::DVec3::from_array(max); - ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) - }); + let (centre, radius) = + bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { + let min = glam::DVec3::from_array(min); + let max = glam::DVec3::from_array(max); + ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) + }); (targets, centre, radius, scene.active_gaze_dir().as_dvec3()) }; let c = SliceCommand::new(targets, view_normal, centre, radius); @@ -1990,7 +2078,10 @@ impl OpenCADStudio { return Some(self.slice_selected(plane, Some(side))); } None => self.command_line.push_info( - crate::t!("Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)").as_ref(), + crate::t!( + "Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)" + ) + .as_ref(), ), } } @@ -2006,8 +2097,7 @@ impl OpenCADStudio { let styles = scene.document.text_styles.iter().cloned().collect(); (defaults, styles, annotation_multiplier) }; - let new_cmd = - TextCommand::with_defaults(defaults, styles, annotation_multiplier); + let new_cmd = TextCommand::with_defaults(defaults, styles, annotation_multiplier); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -2092,9 +2182,15 @@ mod region_tests { app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); let i = app.active_tab; - let sources: Vec<_> = app.tabs[i].scene.document.entities() + let sources: Vec<_> = app.tabs[i] + .scene + .document + .entities() .map(|entity| { - (entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) + ( + entity.common().handle, + matches!(entity, acadrust::EntityType::Circle(_)), + ) }) .collect(); assert_eq!(sources.len(), 2); @@ -2110,7 +2206,10 @@ mod region_tests { !delete_sources || !is_circle, ); } - let region_count = app.tabs[i].scene.document.entities() + let region_count = app.tabs[i] + .scene + .document + .entities() .filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) .count(); assert_eq!(region_count, 1); diff --git a/src/app/document.rs b/src/app/document.rs index 6bdcd7329..50d4cfc4b 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -6,11 +6,11 @@ use crate::scene::pick::grip::GripEdit; use crate::scene::GripDef; use crate::scene::{ObjectIsolationState, Scene}; use crate::snap::SnapResult; +use crate::t; use crate::ui::{LayerPanel, PropertiesPanel}; use acadrust::tables::{normalize_name, Ucs}; use acadrust::{CadDocument, EntityType, Handle}; use iced; -use crate::t; use std::any::Any; use std::collections::HashMap; use std::path::PathBuf; @@ -240,14 +240,18 @@ impl DocumentTab { .and_then(|index| self.block_edits.get_mut(index)) } - /// The [`crate::scene::sketch_constraints::SketchScope`] a new persistent + /// The [`crate::scene::parametric_constraints::ParametricScope`] a new persistent /// constraint should attach to right now — whatever's actually being /// edited: the open block definition if a BEDIT session is active, model /// space otherwise. - pub(super) fn current_sketch_scope(&self) -> crate::scene::sketch_constraints::SketchScope { + pub(super) fn current_parametric_scope( + &self, + ) -> crate::scene::parametric_constraints::ParametricScope { match self.active_block_edit_session() { - Some(session) => crate::scene::sketch_constraints::SketchScope::Block(session.br_handle), - None => crate::scene::sketch_constraints::SketchScope::ModelSpace, + Some(session) => { + crate::scene::parametric_constraints::ParametricScope::Block(session.br_handle) + } + None => crate::scene::parametric_constraints::ParametricScope::ModelSpace, } } @@ -291,8 +295,7 @@ impl DocumentTab { u.origin = h.model_space_ucs_origin; u.x_axis = h.model_space_ucs_x_axis; u.y_axis = h.model_space_ucs_y_axis; - if h.model_space_ucs_name.is_empty() - && super::helpers::UcsXform::from_ucs(&u).is_identity() + if h.model_space_ucs_name.is_empty() && super::helpers::UcsXform::from_ucs(&u).is_identity() { None } else { @@ -475,31 +478,30 @@ impl DocumentTab { } } } else { - let (origin, x_axis, y_axis, elevation, ortho, named, base) = - match &self.active_ucs { - Some(ucs) => ( - ucs.origin, - ucs.x_axis, - ucs.y_axis, - ucs.elevation, - ucs.ortho_type, - if ucs.named_ucs_handle.is_valid() { - ucs.named_ucs_handle - } else { - ucs.handle - }, - ucs.base_ucs_handle, - ), - None => ( - Vector3::ZERO, - Vector3::UNIT_X, - Vector3::UNIT_Y, - 0.0, - 0, - Handle::NULL, - Handle::NULL, - ), - }; + let (origin, x_axis, y_axis, elevation, ortho, named, base) = match &self.active_ucs { + Some(ucs) => ( + ucs.origin, + ucs.x_axis, + ucs.y_axis, + ucs.elevation, + ucs.ortho_type, + if ucs.named_ucs_handle.is_valid() { + ucs.named_ucs_handle + } else { + ucs.handle + }, + ucs.base_ucs_handle, + ), + None => ( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + 0.0, + 0, + Handle::NULL, + Handle::NULL, + ), + }; for object in self.scene.document.objects.values_mut() { let acadrust::objects::ObjectType::Layout(layout) = object else { continue; @@ -656,7 +658,7 @@ impl DocumentTab { #[derive(Clone)] pub(super) enum HistorySnapshot { // Boxed: `DeltaSnapshot` grew past the other variants once - // `sketch_constraints` (ERASE-undo fix) was added, and every undo/redo + // `parametric_constraints` (ERASE-undo fix) was added, and every undo/redo // stack slot costs as much as this enum's largest variant regardless of // which one it actually holds. Delta(Box), @@ -687,41 +689,33 @@ impl HistorySnapshot { ) .saturating_add(d.selected_before.len().saturating_mul(16)) .saturating_add(d.selected_after.len().saturating_mul(16)) + .saturating_add(d.active_layer.as_ref().map_or(0, |(before, after)| { + before.len().saturating_add(after.len()) + })) .saturating_add( - d.active_layer - .as_ref() - .map_or(0, |(before, after)| before.len().saturating_add(after.len())), - ) - .saturating_add( - d.sketch_constraints + d.parametric_constraints .iter() - .map(|entry| entry.before.constraints.len().saturating_add(entry.after.constraints.len())) + .map(|entry| { + entry + .before + .constraints + .len() + .saturating_add(entry.after.constraints.len()) + }) .sum::() .saturating_mul(96), ) - .saturating_add( - d.named_parameters - .as_ref() - .map_or(0, |(before, after)| before.len().saturating_add(after.len()).saturating_mul(128)), - ) + .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| { + before.len().saturating_add(after.len()).saturating_mul(128) + })) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v .before .hidden .len() - .saturating_add( - v.before - .keep - .as_ref() - .map_or(0, rustc_hash::FxHashSet::len), - ) + .saturating_add(v.before.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len)) .saturating_add(v.after.hidden.len()) - .saturating_add( - v.after - .keep - .as_ref() - .map_or(0, rustc_hash::FxHashSet::len), - ) + .saturating_add(v.after.keep.as_ref().map_or(0, rustc_hash::FxHashSet::len)) .saturating_add(v.selected_before.len()) .saturating_add(v.selected_after.len()) .saturating_mul(16) @@ -760,12 +754,12 @@ pub(super) struct DeltaSnapshot { /// Opposite non-entity document state. `apply_delta_state` swaps this with /// the live structure, so the same allocation shuttles between undo/redo. pub(super) structure: Option, - /// Sketch-constraint scopes this same command changed alongside its + /// Parametric-constraint scopes this same command changed alongside its /// entities (e.g. ERASE removing a constraint set's touched constraints) - /// — `sketch_constraints` lives on `Scene`, not in `document`/ + /// — `parametric_constraints` lives on `Scene`, not in `document`/ /// `document.objects`, so it needs its own channel here rather than /// riding along with `entities`/`structure`. Almost always empty. - pub(super) sketch_constraints: Vec, + pub(super) parametric_constraints: Vec, /// Drawing-wide parameter table changed by this transaction. pub(super) named_parameters: Option<( crate::scene::named_parameters::ParameterTable, @@ -831,13 +825,13 @@ pub(super) struct ObjectEntryDelta { pub(super) after: Option, } -/// One sketch-constraint scope's before/after image within an entity delta. +/// One parametric-constraint scope's before/after image within an entity delta. /// This keeps constraint cleanup atomic with edits such as entity erasure. #[derive(Clone)] -pub(super) struct SketchConstraintsEntryDelta { - pub(super) scope: crate::scene::sketch_constraints::SketchScope, - pub(super) before: crate::scene::sketch_constraints::SketchConstraintSet, - pub(super) after: crate::scene::sketch_constraints::SketchConstraintSet, +pub(super) struct ParametricConstraintsEntryDelta { + pub(super) scope: crate::scene::parametric_constraints::ParametricScope, + pub(super) before: crate::scene::parametric_constraints::ParametricConstraintSet, + pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet, } #[derive(Default)] diff --git a/src/app/history.rs b/src/app/history.rs index a43d387e0..421c495d0 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,7 +1,8 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - PendingHistorySnapshot, SketchConstraintsEntryDelta, StructureSnapshot, TableEntryDelta, + ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot, + TableEntryDelta, }, OpenCADStudio, }; @@ -167,8 +168,7 @@ impl OpenCADStudio { ) { self.finish_pending_history(i); let after = self.tabs[i].scene.object_isolation.clone(); - let selected_after: Vec = - self.tabs[i].scene.selected.iter().copied().collect(); + let selected_after: Vec = self.tabs[i].scene.selected.iter().copied().collect(); if before == after && selected_before == selected_after { return; } @@ -225,7 +225,7 @@ impl OpenCADStudio { dirty_after: true, active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: label.into(), }; @@ -233,9 +233,7 @@ impl OpenCADStudio { } pub(super) fn defer_live_entity_history_after(&mut self, i: usize, handle: Handle) { - let Some(HistorySnapshot::Delta(delta)) = - self.tabs[i].history.undo_stack.last_mut() - else { + let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else { return; }; if let Some((_, _, after)) = delta @@ -254,9 +252,7 @@ impl OpenCADStudio { let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let current_layout_after = self.tabs[i].scene.current_layout.clone(); let dirty_after = self.tabs[i].dirty; - let Some(HistorySnapshot::Delta(delta)) = - self.tabs[i].history.undo_stack.last_mut() - else { + let Some(HistorySnapshot::Delta(delta)) = self.tabs[i].history.undo_stack.last_mut() else { return; }; let Some((_, _, entry_after)) = delta @@ -335,8 +331,9 @@ impl OpenCADStudio { dirty_after, active_layer: (pending.active_layer != active_layer_after) .then_some((pending.active_layer, active_layer_after)), - structure: structure_changed.then_some(StructureSnapshot::Full(pending.structure_before)), - sketch_constraints: Vec::new(), + structure: structure_changed + .then_some(StructureSnapshot::Full(pending.structure_before)), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -445,7 +442,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -502,7 +499,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -559,7 +556,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -624,7 +621,7 @@ impl OpenCADStudio { dirty_after: self.tabs[i].dirty, active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: pending.label, }; @@ -659,7 +656,7 @@ impl OpenCADStudio { dim_names, object_handles, }), - sketch_constraints: Vec::new(), + parametric_constraints: Vec::new(), named_parameters: None, label: "STYLE".to_string(), }; @@ -741,15 +738,16 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before, sketch_constraints_before, named_parameters_before) = + let (entity_before, object_before, parametric_constraints_before, named_parameters_before) = rec.into_recorded_images(); - let entities: Vec<(Handle, Option>, Option>)> = entity_before - .into_iter() - .map(|(h, before)| { - let after = self.tabs[i].scene.document.get_entity_arc(h); - (h, before, after) - }) - .collect(); + let entities: Vec<(Handle, Option>, Option>)> = + entity_before + .into_iter() + .map(|(h, before)| { + let after = self.tabs[i].scene.document.get_entity_arc(h); + (h, before, after) + }) + .collect(); let objects: Vec = object_before .into_iter() .filter_map(|(handle, before)| { @@ -761,21 +759,28 @@ impl OpenCADStudio { }) }) .collect(); - let sketch_constraints: Vec = sketch_constraints_before - .into_iter() - .filter_map(|(scope, before)| { - let after = self.tabs[i] - .scene - .sketch_constraint_set(scope) - .cloned() - .unwrap_or_else(|| crate::scene::sketch_constraints::SketchConstraintSet::new(scope)); - (before.constraints != after.constraints).then_some(SketchConstraintsEntryDelta { - scope, - before, - after, + let parametric_constraints: Vec = + parametric_constraints_before + .into_iter() + .filter_map(|(scope, before)| { + let after = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + (before.constraints != after.constraints).then_some( + ParametricConstraintsEntryDelta { + scope, + before, + after, + }, + ) }) - }) - .collect(); + .collect(); let named_parameters = named_parameters_before.and_then(|before| { let after = self.tabs[i].scene.named_parameters().clone(); (before != after).then_some((before, after)) @@ -815,7 +820,7 @@ impl OpenCADStudio { dirty_after, active_layer: None, structure, - sketch_constraints, + parametric_constraints, named_parameters, label: pending.label, }; @@ -839,9 +844,10 @@ impl OpenCADStudio { if let Some(structure) = d.structure.as_mut() { match structure { StructureSnapshot::Full(stored) => { - let inverse = self.tabs[i].scene.document.swap_structure( - std::mem::replace(stored, acadrust::CadDocument::new()), - ); + let inverse = self.tabs[i] + .scene + .document + .swap_structure(std::mem::replace(stored, acadrust::CadDocument::new())); *stored = inverse; self.tabs[i].scene.invalidate_dependency_index(); } @@ -945,9 +951,9 @@ impl OpenCADStudio { } let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let scene = &mut self.tabs[i].scene; - for entry in &d.sketch_constraints { + for entry in &d.parametric_constraints { let value = if undo { &entry.before } else { &entry.after }; - *scene.sketch_constraint_set_mut(entry.scope) = value.clone(); + *scene.parametric_constraint_set_mut(entry.scope) = value.clone(); } if let Some((before, after)) = &d.named_parameters { scene.named_parameters = if undo { before } else { after }.clone(); @@ -1070,6 +1076,7 @@ impl OpenCADStudio { } } } + scene.sync_native_parametric_graph(); scene.clear_preview_wire(); } // UCS state is cached separately from its persisted header/viewport @@ -1104,7 +1111,8 @@ impl OpenCADStudio { let available = self.tabs[i].history.undo_stack.len(); let steps = steps.min(available); if steps == 0 { - self.command_line.push_info(crate::t!("Nothing to undo.").as_ref()); + self.command_line + .push_info(crate::t!("Nothing to undo.").as_ref()); return; } @@ -1127,14 +1135,15 @@ impl OpenCADStudio { structure, StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) ) - }) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { - before - .as_deref() - .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - || after + }) || d.active_layer.is_some() + || d.entities.iter().any(|(_, before, after)| { + before .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - }); + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, true)); self.tabs[i] @@ -1162,7 +1171,8 @@ impl OpenCADStudio { let available = self.tabs[i].history.redo_stack.len(); let steps = steps.min(available); if steps == 0 { - self.command_line.push_info(crate::t!("Nothing to redo.").as_ref()); + self.command_line + .push_info(crate::t!("Nothing to redo.").as_ref()); return; } @@ -1182,14 +1192,15 @@ impl OpenCADStudio { structure, StructureSnapshot::Full(_) | StructureSnapshot::Layers(_) ) - }) || d.active_layer.is_some() || d.entities.iter().any(|(_, before, after)| { - before - .as_deref() - .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - || after + }) || d.active_layer.is_some() + || d.entities.iter().any(|(_, before, after)| { + before .as_deref() .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) - }); + || after + .as_deref() + .is_some_and(|entity| matches!(entity, EntityType::Viewport(_))) + }); had_full |= d.structure.as_ref().is_some_and(StructureSnapshot::is_full); changes.extend(self.apply_delta_state(i, &mut d, false)); self.tabs[i] diff --git a/src/app/mod.rs b/src/app/mod.rs index 0ad215b09..50a864cf0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,6 +7,7 @@ pub(crate) fn automation_action_names() -> &'static [&'static str] { pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; +mod annotation_data; mod command_driver; pub(crate) mod commands; mod document; @@ -30,7 +31,6 @@ mod startup; mod style_ops; mod text_inline; mod tolerance_dialog; -mod annotation_data; mod update; mod view; mod visibility; @@ -260,12 +260,12 @@ pub(crate) enum FindMatchKey { } use crate::snap::Snapper; use crate::ui::{CommandLine, Ribbon, StatusBar}; -use acadrust::CadDocument; use acadrust::types::{Color as AcadColor, LineWeight}; +use acadrust::CadDocument; use iced::time::Instant; use iced::window; -use iced::{Point, Task, Theme, mouse}; +use iced::{mouse, Point, Task, Theme}; use std::sync::Arc; pub(super) const POLY_START_DELAY_MS: u128 = 150; @@ -552,11 +552,7 @@ pub(super) struct OpenCADStudio { /// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// handler on each launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving creates native constraint objects alongside the - /// application's own persistence record. Existing native objects remain - /// synchronized regardless of this setting. - pub write_dwg_native_constraints: bool, - /// When true (default), a sketch constraint's viewport pill shows its + /// When true (default), a parametric constraint's viewport pill shows its /// glyph plus a driven value or named-parameter name. When false, every /// pill shows only the glyph. pub show_constraint_values: bool, @@ -2118,8 +2114,6 @@ pub enum Message { /// Register or unregister as the .dwg/.dxf handler, from Options. Same /// setting the FILEASSOC command carries. FileAssocChanged(bool), - /// Toggle writing native constraint objects on save. - WriteDwgNativeConstraintsChanged(bool), /// Toggle showing driven values/named-parameter names on constraint /// pills, from Options. See `show_constraint_values`'s doc comment. ShowConstraintValuesChanged(bool), @@ -2547,9 +2541,9 @@ pub enum Message { /// Cycle the coordinate readout mode ($COORDS): static → live → polar. CycleCoordsMode, /// Removes one flagged redundant or conflicting constraint from the - /// current sketch scope. + /// current parametric scope. /// No-op if the scope currently has no flagged conflict. - ResolveOneSketchConflict, + ResolveOneParametricConflict, /// Toggle the status-bar customization menu open/closed. ToggleStatusBarMenu, /// Close the status-bar customization menu. @@ -3636,7 +3630,6 @@ impl OpenCADStudio { dimension_continue_mode: 1, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, savetime_min: 10, default_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 7096e7b03..393467b2d 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1,10 +1,10 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word}; use super::{OpenCADStudio, VARIES_LABEL}; use crate::io::linetypes; -use crate::scene::view::dispatch; use crate::scene::model::object::PropValue; -use crate::ui; +use crate::scene::view::dispatch; use crate::t; +use crate::ui; use acadrust::types::{Transform, Vector3}; use acadrust::{Entity, EntityType, Handle}; @@ -37,9 +37,7 @@ impl OpenCADStudio { // building the panel, and syncing the ribbon. let t_all = crate::perf::enabled().then(iced::time::Instant::now); let i = self.active_tab; - if !crate::entities::object_data::cache_is_prepared( - &self.tabs[i].scene.object_data_cache, - ) { + if !crate::entities::object_data::cache_is_prepared(&self.tabs[i].scene.object_data_cache) { self.tabs[i].scene.object_data_cache = crate::entities::object_data::build_cache(&self.tabs[i].scene.document); } @@ -153,7 +151,9 @@ impl OpenCADStudio { } _ => None, }); - vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) + vertex_count.map_or(prop_vertex, |count| { + prop_vertex.min(count.saturating_sub(1)) + }) } else { prop_vertex }; @@ -202,9 +202,7 @@ impl OpenCADStudio { } else if !header.current_linetype_handle.is_null() { doc.line_types .iter() - .find(|line_type| { - line_type.handle == header.current_linetype_handle - }) + .find(|line_type| line_type.handle == header.current_linetype_handle) .map(|line_type| line_type.name.clone()) .unwrap_or_else(|| "ByLayer".to_string()) } else { @@ -308,7 +306,10 @@ impl OpenCADStudio { PropSection { title: t!("General").into_owned(), props: vec![ - read_only(t!("AC Version").as_ref(), doc.version.as_str().to_string()), + read_only( + t!("AC Version").as_ref(), + doc.version.as_str().to_string(), + ), Property { label: t!("Color").into_owned(), field: "color", @@ -341,11 +342,20 @@ impl OpenCADStudio { label: t!("Transparency").into_owned(), field: "transparency", value: PropValue::EditChoice { - value: crate::scene::creation_style::current_transparency_label(self.tabs[i].scene.document.current_entity_transparency()), + value: + crate::scene::creation_style::current_transparency_label( + self.tabs[i] + .scene + .document + .current_entity_transparency(), + ), options: vec!["ByLayer".to_string(), "ByBlock".to_string()], }, }, - read_only(t!("Thickness").as_ref(), format_length(header.thickness)), + read_only( + t!("Thickness").as_ref(), + format_length(header.thickness), + ), ], }, PropSection { @@ -440,7 +450,8 @@ impl OpenCADStudio { label: t!("UCS icon On").into_owned(), field: "view_ucs_icon_on", value: PropValue::Choice { - selected: if ucs_icon_on { "Yes" } else { "No" }.to_string(), + selected: if ucs_icon_on { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -448,7 +459,8 @@ impl OpenCADStudio { label: t!("UCS icon at origin").into_owned(), field: "view_ucs_icon_at_origin", value: PropValue::Choice { - selected: if ucs_icon_at_origin { "Yes" } else { "No" }.to_string(), + selected: if ucs_icon_at_origin { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -456,7 +468,8 @@ impl OpenCADStudio { label: t!("UCS per viewport").into_owned(), field: "view_ucs_per_viewport", value: PropValue::Choice { - selected: if ucs_per_viewport { "Yes" } else { "No" }.to_string(), + selected: if ucs_per_viewport { "Yes" } else { "No" } + .to_string(), options: vec!["Yes".to_string(), "No".to_string()], }, }, @@ -478,7 +491,9 @@ impl OpenCADStudio { .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -493,9 +508,7 @@ impl OpenCADStudio { .collect(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), - linetype_combo: iced::widget::combo_box::State::new( - linetype_items.clone(), - ), + linetype_combo: iced::widget::combo_box::State::new(linetype_items.clone()), lineweight_combo: iced::widget::combo_box::State::new( ui::properties::lw_options(), ), @@ -515,7 +528,8 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane); + let display_entity = + dispatch::entity_in_working_plane(contextual.as_ref(), plane); let entity = &display_entity; let group_names = self.tabs[i].scene.group_names_for_entity(handle); let compact_solid = @@ -528,12 +542,10 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - sections.extend( - crate::scene::model::solid_history::primitive_properties( - &self.tabs[i].scene.document, - handle, - ), - ); + sections.extend(crate::scene::model::solid_history::primitive_properties( + &self.tabs[i].scene.document, + handle, + )); // Turn the Material row into an editable picker: the source // options (ByLayer / ByBlock) plus every named material the @@ -606,10 +618,11 @@ impl OpenCADStudio { if let Some(row) = section.props.iter_mut().find(|row| row.field == "color") { - row.value = crate::scene::model::object::PropValue::NamedColorChoice { - color, - name: color_name.clone(), - }; + row.value = + crate::scene::model::object::PropValue::NamedColorChoice { + color, + name: color_name.clone(), + }; } } use crate::entities::common::ro_prop; @@ -788,7 +801,12 @@ impl OpenCADStudio { metrics .principal_directions .chunks_exact(3) - .map(|axis| format!("({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2])) + .map(|axis| { + format!( + "({:.6}, {:.6}, {:.6})", + axis[0], axis[1], axis[2] + ) + }) .collect::>() .join(", "), ), @@ -870,11 +888,9 @@ impl OpenCADStudio { if let acadrust::EntityType::Insert(ins) = entity { let eq = (ins.x_scale() - ins.y_scale()).abs() < 1e-12 && (ins.x_scale() - ins.z_scale()).abs() < 1e-12; - let uniform = - eq && !self.props_asym_scale.contains(&handle.value()); + let uniform = eq && !self.props_asym_scale.contains(&handle.value()); for section in sections.iter_mut() { - let Some(xi) = - section.props.iter().position(|p| p.field == "x_scale") + let Some(xi) = section.props.iter().position(|p| p.field == "x_scale") else { continue; }; @@ -930,12 +946,14 @@ impl OpenCADStudio { _ => common .plotstyle_handle .and_then(|ph| { - crate::scene::annotative::as_dict(doc, dict_h).and_then(|dict| { - dict.entries - .iter() - .find(|(_, h)| *h == ph) - .map(|(n, _)| n.clone()) - }) + crate::scene::annotative::as_dict(doc, dict_h).and_then( + |dict| { + dict.entries + .iter() + .find(|(_, h)| *h == ph) + .map(|(n, _)| n.clone()) + }, + ) }) .unwrap_or_else(|| "ByLayer".to_string()), }; @@ -1025,19 +1043,28 @@ impl OpenCADStudio { let cur_tstyle = ml .text_style_handle .and_then(|h| { - doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) + doc.text_styles + .iter() + .find(|s| s.handle == h) + .map(|s| s.name.clone()) }) .unwrap_or_else(|| "Standard".to_string()); let cur_arrow = ml .arrowhead_handle .and_then(|h| { - doc.block_records.iter().find(|b| b.handle == h).map(|b| b.name.clone()) + doc.block_records + .iter() + .find(|b| b.handle == h) + .map(|b| b.name.clone()) }) .unwrap_or_else(|| "Closed filled".to_string()); let cur_ltype = ml .line_type_handle .and_then(|h| { - doc.line_types.iter().find(|l| l.handle == h).map(|l| l.name.clone()) + doc.line_types + .iter() + .find(|l| l.handle == h) + .map(|l| l.name.clone()) }) .unwrap_or_else(|| "ByBlock".to_string()); let cur_block = ml @@ -1231,7 +1258,9 @@ impl OpenCADStudio { .find(|property| property.field == "style_name") { let current = match &prop.value { - crate::scene::model::object::PropValue::PlainText(s) => s.clone(), + crate::scene::model::object::PropValue::PlainText(s) => { + s.clone() + } _ => String::new(), }; prop.value = crate::scene::model::object::PropValue::Choice { @@ -1262,7 +1291,9 @@ impl OpenCADStudio { use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; let dim_c = dov::color(&d.base().common.extended_data, dov::DIMCLRD) - .unwrap_or_else(|| acadrust::types::Color::from_index(style.dimclrd)); + .unwrap_or_else(|| { + acadrust::types::Color::from_index(style.dimclrd) + }); set_row_value( &mut sections, "dim_line_color", @@ -1276,11 +1307,7 @@ impl OpenCADStudio { .unwrap_or_else(|| { acadrust::types::Color::from_index(inherited) }); - set_row_value( - &mut sections, - field, - PropValue::ColorChoice(color), - ); + set_row_value(&mut sections, field, PropValue::ColorChoice(color)); } } } @@ -1311,9 +1338,7 @@ impl OpenCADStudio { // insert of it follows). Anonymous (*) and // xref(-dependent) blocks keep the read-only row. let regular = |br: &acadrust::tables::BlockRecord| { - !br.is_anonymous() - && !br.flags.is_xref - && !br.name.contains('|') + !br.is_anonymous() && !br.flags.is_xref && !br.name.contains('|') }; let editable = doc .block_records @@ -1327,9 +1352,7 @@ impl OpenCADStudio { .filter(|br| regular(br)) .map(|br| br.name.clone()) .collect(); - options.sort_by(|a, b| { - a.to_lowercase().cmp(&b.to_lowercase()) - }); + options.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); set_row_value( &mut sections, "block", @@ -1379,9 +1402,9 @@ impl OpenCADStudio { .find(|p| p.field == "dimension_style") { let cur = match &p.value { - crate::scene::model::object::PropValue::PlainText(s) => { - s.clone() - } + crate::scene::model::object::PropValue::PlainText( + s, + ) => s.clone(), _ => ld.dimension_style.clone(), }; p.value = crate::scene::model::object::PropValue::Choice { @@ -1456,8 +1479,9 @@ impl OpenCADStudio { ); // A per-object color override wins over the style. - let dim_c = dov::color(xd, dov::DIMCLRD) - .unwrap_or_else(|| acadrust::types::Color::from_index(ds.dimclrd)); + let dim_c = dov::color(xd, dov::DIMCLRD).unwrap_or_else(|| { + acadrust::types::Color::from_index(ds.dimclrd) + }); set_row_value( &mut sections, "dim_line_color", @@ -1494,9 +1518,8 @@ impl OpenCADStudio { acadrust::EntityType::Tolerance(tol) => { use crate::entities::dim_override as dov; let style = crate::entities::tolerance::resolve_dim_style(tol, doc); - let style_name = style - .map(|entry| entry.name.clone()) - .unwrap_or_else(|| { + let style_name = + style.map(|entry| entry.name.clone()).unwrap_or_else(|| { if tol.dimension_style_name.trim().is_empty() { "Standard".to_string() } else { @@ -1524,24 +1547,18 @@ impl OpenCADStudio { }, ); - let text_style_name = dov::handle( - &tol.common.extended_data, - dov::DIMTXSTY, - ) - .and_then(|handle| { - doc.text_styles - .iter() - .find(|entry| entry.handle == handle) - }) - .map(|entry| entry.name.clone()) - .or_else(|| style.map(|entry| entry.dimtxsty.clone())) - .unwrap_or_else(|| "Standard".to_string()); + let text_style_name = + dov::handle(&tol.common.extended_data, dov::DIMTXSTY) + .and_then(|handle| { + doc.text_styles.iter().find(|entry| entry.handle == handle) + }) + .map(|entry| entry.name.clone()) + .or_else(|| style.map(|entry| entry.dimtxsty.clone())) + .unwrap_or_else(|| "Standard".to_string()); let text_height_editable = doc .text_styles .iter() - .find(|entry| { - entry.name.eq_ignore_ascii_case(&text_style_name) - }) + .find(|entry| entry.name.eq_ignore_ascii_case(&text_style_name)) .is_none_or(|entry| !entry.has_fixed_height()); let mut names = text_style_names.clone(); if !names @@ -1559,12 +1576,9 @@ impl OpenCADStudio { }, ); - let height = dov::real( - &tol.common.extended_data, - dov::DIMTXT, - ) - .or_else(|| style.map(|entry| entry.dimtxt)) - .unwrap_or(tol.text_height); + let height = dov::real(&tol.common.extended_data, dov::DIMTXT) + .or_else(|| style.map(|entry| entry.dimtxt)) + .unwrap_or(tol.text_height); set_row_value( &mut sections, "tol_text_height", @@ -1588,9 +1602,9 @@ impl OpenCADStudio { set_row_value( &mut sections, "text_height", - PropValue::ReadOnly( - crate::entities::common::format_length(ml.text_height), - ), + PropValue::ReadOnly(crate::entities::common::format_length( + ml.text_height, + )), ); } if let Some(sh) = ml.style_handle { @@ -1645,15 +1659,16 @@ impl OpenCADStudio { .table_style_handle .and_then(|handle| doc.objects.get(&handle)) .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) - } + acadrust::objects::ObjectType::TableStyle(style) => Some(style), _ => None, }); let selected = selected_style .map(|style| style.name.clone()) .unwrap_or_else(|| "Standard".to_string()); - if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + if !names + .iter() + .any(|name| name.eq_ignore_ascii_case(&selected)) + { names.insert(0, selected.clone()); } set_row_value( @@ -1675,10 +1690,8 @@ impl OpenCADStudio { table, selected_style, ); - let flow_up = crate::entities::table::resolved_flow_up( - table, - selected_style, - ); + let flow_up = + crate::entities::table::resolved_flow_up(table, selected_style); let (horizontal_margin, vertical_margin) = crate::entities::table::resolved_table_margins( table, @@ -1713,9 +1726,7 @@ impl OpenCADStudio { let columns = table.column_count(); if columns > 0 { let cell_index = prop_vertex.min( - table.row_count() - .saturating_mul(columns) - .saturating_sub(1), + table.row_count().saturating_mul(columns).saturating_sub(1), ); let row_index = cell_index / columns; let column_index = cell_index % columns; @@ -1796,14 +1807,13 @@ impl OpenCADStudio { "tbl_cell_text_height", crate::entities::common::format_length(text_height), ); - let content_color = local( - CellStylePropertyFlags::CONTENT_COLOR, - ) - .map(|style| style.content_color) - .or_else(|| { - document_row_style.map(|style| style.text_color) - }) - .unwrap_or(acadrust::types::Color::ByBlock); + let content_color = + local(CellStylePropertyFlags::CONTENT_COLOR) + .map(|style| style.content_color) + .or_else(|| { + document_row_style.map(|style| style.text_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); update_row_color( &mut sections, "tbl_cell_content_color", @@ -1828,8 +1838,7 @@ impl OpenCADStudio { background_style .map(|style| style.fill_enabled) .or_else(|| { - document_row_style - .map(|style| style.fill_enabled) + document_row_style.map(|style| style.fill_enabled) }) .unwrap_or(false), ); @@ -1840,23 +1849,18 @@ impl OpenCADStudio { .map(|style| style.format_string.clone()) }) .unwrap_or_default(); - update_row_text( - &mut sections, - "tbl_cell_format", - format, - ); + update_row_text(&mut sections, "tbl_cell_format", format); let data_type = cell .contents .first() .map(|content| content.value.value_type) .filter(|kind| *kind != CellValueType::Unknown) .or_else(|| { - local(CellStylePropertyFlags::DATA_TYPE) - .map(|style| { - CellValueType::from( - style.value_data_type.max(0) as u32, - ) - }) + local(CellStylePropertyFlags::DATA_TYPE).map(|style| { + CellValueType::from( + style.value_data_type.max(0) as u32 + ) + }) }) .or_else(|| { document_row_style.map(|style| { @@ -1927,9 +1931,7 @@ impl OpenCADStudio { ] .into_iter() .flatten() - .find(|style| { - style.applied_border_edges.contains(edge) - }); + .find(|style| style.applied_border_edges.contains(edge)); if let Some(style) = local_style { return if edge == CellEdgeFlags::TOP { !style.top_border.invisible @@ -2006,12 +2008,10 @@ impl OpenCADStudio { || match entity { acadrust::EntityType::Dimension( acadrust::entities::Dimension::Arc(dimension), - ) => { - crate::scene::annotative::dim_style_is_annotative( - doc, - &dimension.base.style_name, - ) - } + ) => crate::scene::annotative::dim_style_is_annotative( + doc, + &dimension.base.style_name, + ), acadrust::EntityType::Tolerance(_) => { crate::scene::annotative::annotation_style_is_annotative( doc, entity, @@ -2087,10 +2087,11 @@ impl OpenCADStudio { } } if is_anno { - let memberships = crate::scene::annotative::object_scale_memberships( - doc, - entity.common().handle, - ); + let memberships = + crate::scene::annotative::object_scale_memberships( + doc, + entity.common().handle, + ); let assigned_scales = if memberships.is_empty() { doc.header.current_annotation_scale.clone() } else { @@ -2189,7 +2190,7 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - // Persistent sketch-constraint list for this entity — + // Parametric-constraint list for this entity — // the Properties-panel analogue of the viewport's // constraint glyph pills: one row per constraint // touching `handle`, clickable to select every entity @@ -2198,33 +2199,49 @@ impl OpenCADStudio { // entity (the common case) shouldn't gain empty // bookkeeping. { - let scope = self.tabs[i].current_sketch_scope(); + let scope = self.tabs[i].current_parametric_scope(); let scene = &self.tabs[i].scene; - if let Some(set) = scene.sketch_constraint_set(scope) { + if let Some(set) = scene.parametric_constraint_set(scope) { let props: Vec = set .constraints_touching(handle) .map(|c| { - let conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let conflicting = + set.conflicts.iter().any(|(id, _)| *id == c.id); let value_suffix = match &c.driving_param { - Some(crate::scene::named_parameters::DrivingValue::Literal(v)) => { + Some( + crate::scene::named_parameters::DrivingValue::Literal( + v, + ), + ) => { format!(" = {v:.2}") } - Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { - match scene.named_parameters().resolve(name) { - Ok(v) => format!(" = {name} ({v:.2})"), - Err(_) => format!(" = {name} (?)"), - } - } + Some( + crate::scene::named_parameters::DrivingValue::Named( + name, + ), + ) => match scene.named_parameters().resolve(name) { + Ok(v) => format!(" = {name} ({v:.2})"), + Err(_) => format!(" = {name} (?)"), + }, None => String::new(), }; - let label = format!("{} {:?}{}", c.kind.glyph_symbol(), c.kind, value_suffix); - let mut handles: Vec = c.refs.iter().map(|r| r.entity).collect(); + let label = format!( + "{} {:?}{}", + c.kind.glyph_symbol(), + c.kind, + value_suffix + ); + let mut handles: Vec = + c.refs.iter().map(|r| r.entity).collect(); handles.sort_by_key(|h| h.value()); handles.dedup(); crate::scene::model::object::Property { label, - field: "sketch_constraint", - value: crate::scene::model::object::PropValue::EntityLink { handles, conflicting }, + field: "parametric_constraint", + value: crate::scene::model::object::PropValue::EntityLink { + handles, + conflicting, + }, } }) .collect(); @@ -2270,7 +2287,9 @@ impl OpenCADStudio { .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -2335,20 +2354,19 @@ impl OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = - filtered - .iter() - .map(|(handle, entity)| { - let local = if plane.is_identity() { - std::borrow::Cow::Borrowed(*entity) - } else { - std::borrow::Cow::Owned( - dispatch::entity_in_working_plane(entity, plane), - ) - }; - (*handle, local) - }) - .collect(); + let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = filtered + .iter() + .map(|(handle, entity)| { + let local = if plane.is_identity() { + std::borrow::Cow::Borrowed(*entity) + } else { + std::borrow::Cow::Owned(dispatch::entity_in_working_plane( + entity, plane, + )) + }; + (*handle, local) + }) + .collect(); let local_refs: Vec<(Handle, &EntityType)> = local_entities .iter() .map(|(handle, entity)| (*handle, entity.as_ref())) @@ -2381,7 +2399,10 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, - &local_refs.iter().map(|(handle, _)| *handle).collect::>(), + &local_refs + .iter() + .map(|(handle, _)| *handle) + .collect::>(), )); if compact_solids { retain_compact_solid_sections(&mut sections); @@ -2391,7 +2412,9 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", .iter() .flat_map(|section| section.props.iter()) .filter_map(|prop| match &prop.value { - crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + crate::scene::model::object::PropValue::Choice { + options, .. + } => Some(( prop.field.to_string(), iced::widget::combo_box::State::new( options @@ -2458,8 +2481,7 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", make_sections_read_only(&mut panel.sections); // Rows demoted to read-only no longer back an editable field; // drop them from the id→key map so focus can't map onto them. - panel.field_key_by_id = - crate::ui::properties::build_field_key_map(&panel.sections); + panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); panel.edit_buf.clear(); panel.active_field = None; panel.color_picker_open = false; @@ -2624,17 +2646,13 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} let is_paper = self.tabs[i].scene.current_layout != "Model"; // Paper-space entity coordinates are NOT offset by world_offset (same rule // as wire tessellation in wires_for_block). Only subtract in model space. - let wo = if is_paper { - [0.0f64; 3] - } else { - [0.0_f64; 3] - }; + let wo = if is_paper { [0.0f64; 3] } else { [0.0_f64; 3] }; let (new_handle, new_grips, new_grip_handles) = { let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); let single_handle = (selected.len() == 1 && !self.tabs[i].scene.is_layer_locked(selected[0].0)) - .then(|| selected[0].0); + .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); // Avoid rebuilding every entity's grip markers above the configured @@ -2729,9 +2747,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .copied() .zip(self.tabs[i].selected_grips.iter().map(|grip| grip.id)) .collect(); - self.tabs[i] - .hot_grips - .retain(|key| available.contains(key)); + self.tabs[i].hot_grips.retain(|key| available.contains(key)); // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); @@ -2768,9 +2784,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} ) { self.tabs[i].scene.invalidate_dim_block_recorded(handle); } - context_object_changed |= self.tabs[i] - .scene - .sync_displayed_annotation_context(handle); + context_object_changed |= self.tabs[i].scene.sync_displayed_annotation_context(handle); // Hatch / SOLID fills render from prebuilt cached models; rebuild // them or pattern edits (scale, background, …) stay invisible // (#415). @@ -2826,10 +2840,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} /// Like [`commit_entity`] but returns the handle the new entity was given /// (or `None` if it could not be added). Lets callers follow up — e.g. /// open the in-place text editor on a freshly created MultiLeader. - pub(super) fn commit_entity_handle( - &mut self, - entity: acadrust::EntityType, - ) -> Option { + pub(super) fn commit_entity_handle(&mut self, entity: acadrust::EntityType) -> Option { self.commit_entity_handle_with_policies(entity, false, false, false) } @@ -2893,9 +2904,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { use acadrust::entities::table::CellStateFlags; - use acadrust::objects::{ - ClassObject, ClassObjectData, DataLink, ObjectType, - }; + use acadrust::objects::{ClassObject, ClassObjectData, DataLink, ObjectType}; let document = &mut self.tabs[i].scene.document; let link_handle = document.allocate_handle(); let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { @@ -2997,7 +3006,11 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // CELTSCALE (header.current_entity_linetype_scale): new entities // pick up the document's saved per-entity linetype scale. The user // can override per entity later via the properties panel. - let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; + let celtscale = self.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale; if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { entity.common_mut().linetype_scale = celtscale; } @@ -3019,12 +3032,12 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // Smart centre objects carry their own drawing-level creation style. // Apply it after the generic ribbon style so ordinary LINE entities // keep the existing path while centre lines honour their settings. - let center_line = acadrust::entities::CenterLineAssociation::read( - &entity.common().extended_data, - ).is_some(); - let center_mark = acadrust::entities::CenterMarkAssociation::read( - &entity.common().extended_data, - ).is_some(); + let center_line = + acadrust::entities::CenterLineAssociation::read(&entity.common().extended_data) + .is_some(); + let center_mark = + acadrust::entities::CenterMarkAssociation::read(&entity.common().extended_data) + .is_some(); if center_line || center_mark { let settings = self.tabs[i].scene.centerline_settings(); if !settings.layer.eq_ignore_ascii_case("Current") { @@ -3039,12 +3052,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } else { acadrust::entities::CENTERLINE_XDATA_APPLICATION }; - if !self.tabs[i] - .scene - .document - .app_ids - .contains(application) - { + if !self.tabs[i].scene.document.app_ids.contains(application) { let mut app = acadrust::tables::AppId::new(application); app.handle = self.tabs[i].scene.document.allocate_handle(); let _ = self.tabs[i].scene.document.app_ids.add(app); @@ -3052,12 +3060,10 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } let text_style_annotative = match &entity { - acadrust::EntityType::Text(text) => { - crate::scene::annotative::text_style_is_annotative( - &self.tabs[i].scene.document, - &text.style, - ) - } + acadrust::EntityType::Text(text) => crate::scene::annotative::text_style_is_annotative( + &self.tabs[i].scene.document, + &text.style, + ), acadrust::EntityType::MText(text) => { crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, @@ -3090,13 +3096,12 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} _ => {} } } - let needs_annotation_context = crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - &entity, - ) || crate::scene::annotative::annotation_style_is_annotative( - &self.tabs[i].scene.document, - &entity, - ); + let needs_annotation_context = + crate::scene::annotative::is_annotative(&self.tabs[i].scene.document, &entity) + || crate::scene::annotative::annotation_style_is_annotative( + &self.tabs[i].scene.document, + &entity, + ); let new_handle = if matches!(&entity, acadrust::EntityType::Viewport(_)) && self.tabs[i].scene.current_layout != "Model" @@ -3181,9 +3186,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } } -fn make_sections_read_only( - sections: &mut [crate::scene::model::object::PropSection], -) { +fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSection]) { use crate::scene::model::object::PropValue; for property in sections @@ -3208,15 +3211,13 @@ fn make_sections_read_only( acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }, PropValue::NamedColorChoice { name, .. } => name.clone(), - PropValue::ColorVaries - | PropValue::LwVaries - | PropValue::FieldLwVaries { .. } => VARIES_LABEL.to_string(), + PropValue::ColorVaries | PropValue::LwVaries | PropValue::FieldLwVaries { .. } => { + VARIES_LABEL.to_string() + } PropValue::LwChoice(lineweight) | PropValue::FieldLwChoice { value: lineweight, .. - } => { - ui::properties::LwItem(*lineweight).to_string() - } + } => ui::properties::LwItem(*lineweight).to_string(), PropValue::BoolToggle { value, .. } => { if *value { t!("Yes") } else { t!("No") }.into_owned() } @@ -3312,9 +3313,9 @@ fn aggregate_solid_history_sections( document: &acadrust::CadDocument, handles: &[Handle], ) -> Vec { - let mut sections = handles.iter().map(|handle| { - crate::scene::model::solid_history::primitive_properties(document, *handle) - }); + let mut sections = handles + .iter() + .map(|handle| crate::scene::model::solid_history::primitive_properties(document, *handle)); let Some(mut merged) = sections.next() else { return Vec::new(); }; @@ -3330,9 +3331,7 @@ fn aggregate_solid_history_sections( merged } -fn retain_compact_solid_sections( - sections: &mut Vec, -) { +fn retain_compact_solid_sections(sections: &mut Vec) { sections.iter_mut().for_each(|section| { let internal_surface_section = section .props @@ -3419,12 +3418,10 @@ fn merge_prop_value( ( PropValue::FieldLwChoice { field, .. }, PropValue::FieldLwChoice { - field: other_field, - .. + field: other_field, .. }, ) if field == other_field => PropValue::FieldLwVaries { field }, - (PropValue::FieldLwVaries { field }, _) - | (_, PropValue::FieldLwVaries { field }) => { + (PropValue::FieldLwVaries { field }, _) | (_, PropValue::FieldLwVaries { field }) => { PropValue::FieldLwVaries { field } } (PropValue::LinetypeChoice(_), PropValue::LinetypeChoice(_)) => { @@ -3504,11 +3501,7 @@ fn merge_prop_value( /// Set the first property row matching `field` (across all sections) to a /// read-only `value`. No-op when the field is absent. Used to fill the /// doc-dependent placeholder rows the entity builders leave empty. -fn set_row( - sections: &mut [crate::scene::model::object::PropSection], - field: &str, - value: String, -) { +fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { row.value = crate::scene::model::object::PropValue::ReadOnly(value); @@ -3539,7 +3532,11 @@ fn update_row_text( ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { @@ -3561,7 +3558,11 @@ fn update_row_toggle( ) { use crate::scene::model::object::PropValue; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { @@ -3592,14 +3593,16 @@ fn update_row_color( acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), }; for section in sections.iter_mut() { - let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + let Some(row) = section + .props + .iter_mut() + .find(|property| property.field == field) + else { continue; }; match &mut row.value { PropValue::ColorChoice(current) => *current = color, - PropValue::NamedColorChoice { .. } => { - row.value = PropValue::ColorChoice(color) - } + PropValue::NamedColorChoice { .. } => row.value = PropValue::ColorChoice(color), PropValue::ReadOnly(current) => *current = label, PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, _ => {} @@ -3795,30 +3798,30 @@ fn format_unit_factor(factor: f64) -> String { /// Convert INSUNITS (DXF group 70) to millimetres. fn insunits_to_mm(code: i16) -> Option { Some(match code { - 1 => 25.4, // Inches - 2 => 304.8, // Feet - 3 => 1_609_344.0, // Miles - 4 => 1.0, // Millimeters - 5 => 10.0, // Centimeters - 6 => 1_000.0, // Meters - 7 => 1_000_000.0, // Kilometers - 8 => 0.000_025_4, // Microinches - 9 => 0.025_4, // Mils - 10 => 914.4, // Yards - 11 => 1.0e-7, // Angstroms - 12 => 1.0e-6, // Nanometers - 13 => 0.001, // Microns - 14 => 100.0, // Decimeters - 15 => 10_000.0, // Decameters - 16 => 100_000.0, // Hectometers - 17 => 1.0e12, // Gigameters - 18 => 1.495_978_707e14, // Astronomical Units - 19 => 9.460_730_472_580_8e18, // Light Years + 1 => 25.4, // Inches + 2 => 304.8, // Feet + 3 => 1_609_344.0, // Miles + 4 => 1.0, // Millimeters + 5 => 10.0, // Centimeters + 6 => 1_000.0, // Meters + 7 => 1_000_000.0, // Kilometers + 8 => 0.000_025_4, // Microinches + 9 => 0.025_4, // Mils + 10 => 914.4, // Yards + 11 => 1.0e-7, // Angstroms + 12 => 1.0e-6, // Nanometers + 13 => 0.001, // Microns + 14 => 100.0, // Decimeters + 15 => 10_000.0, // Decameters + 16 => 100_000.0, // Hectometers + 17 => 1.0e12, // Gigameters + 18 => 1.495_978_707e14, // Astronomical Units + 19 => 9.460_730_472_580_8e18, // Light Years 20 => 3.085_677_581_491_367_3e19, // Parsecs - 21 => 1_200_000.0 / 3_937.0, // US Survey Feet - 22 => 100_000.0 / 3_937.0, // US Survey Inches - 23 => 3_600_000.0 / 3_937.0, // US Survey Yards - 24 => 6_336_000_000.0 / 3_937.0, // US Survey Miles + 21 => 1_200_000.0 / 3_937.0, // US Survey Feet + 22 => 100_000.0 / 3_937.0, // US Survey Inches + 23 => 3_600_000.0 / 3_937.0, // US Survey Yards + 24 => 6_336_000_000.0 / 3_937.0, // US Survey Miles _ => return None, }) } @@ -3842,9 +3845,7 @@ fn apply_insert_unit_scale(ins: &mut acadrust::entities::Insert, ratio: f64) -> #[cfg(test)] mod insert_unit_scale_tests { - use super::{ - apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm, - }; + use super::{apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm}; use acadrust::entities::{AttributeEntity, Insert}; use acadrust::types::Vector3; @@ -3909,10 +3910,7 @@ mod insert_unit_scale_tests { fn astronomical_units_use_precise_si_values() { assert_eq!(insunits_to_mm(18), Some(1.495_978_707e14)); assert_eq!(insunits_to_mm(19), Some(9.460_730_472_580_8e18)); - assert_eq!( - insunits_to_mm(20), - Some(3.085_677_581_491_367_3e19) - ); + assert_eq!(insunits_to_mm(20), Some(3.085_677_581_491_367_3e19)); assert_ne!(format_unit_factor(1.0e-7), "0.0000"); } @@ -4004,11 +4002,7 @@ mod apply_property_op_tests { app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| { count2.set(count2.get() + 1); - if let Some(e) = app2.tabs[app2.active_tab] - .scene - .document - .get_entity_mut(h) - { + if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); @@ -4038,19 +4032,22 @@ mod apply_property_op_tests { app.tabs[i].dirty = false; app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| { count2.set(count2.get() + 1); - if let Some(e) = app2.tabs[app2.active_tab] - .scene - .document - .get_entity_mut(h) - { + if let Some(e) = app2.tabs[app2.active_tab].scene.document.get_entity_mut(h) { e.common_mut().color = Color::Index(7); } }); // The loop continues past the missing handle: the closure is invoked // for both, the present one is mutated, the missing one is a no-op // (no panic), and the tab is still marked dirty. - assert_eq!(count.get(), 2, "closure still called for the missing handle"); - assert!(app.tabs[i].dirty, "tab marked dirty even with a missing handle"); + assert_eq!( + count.get(), + 2, + "closure still called for the missing handle" + ); + assert!( + app.tabs[i].dirty, + "tab marked dirty even with a missing handle" + ); let e = app.tabs[i] .scene .document @@ -4111,32 +4108,68 @@ mod chprop_integration_tests { let _ = app.update(Message::PropColorChanged(Color::Index(5))); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, Color::Index(5) ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, Color::Index(5) ); assert!(app.tabs[i].dirty, "helper must set the dirty bit"); app.undo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, original ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, original ); app.redo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h1).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h1) + .unwrap() + .common() + .color, Color::Index(5) ); assert_eq!( - app.tabs[i].scene.document.get_entity(h2).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h2) + .unwrap() + .common() + .color, Color::Index(5) ); } @@ -4164,14 +4197,26 @@ mod chprop_integration_tests { let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#); let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#); assert_ne!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, original, "CHPROP COLOR 3 must have changed the line's colour" ); app.undo_active_tab(); assert_eq!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, original, "undo must restore the pre-CHPROP colour" ); @@ -4215,7 +4260,13 @@ mod chprop_integration_tests { let _ = app.update(Message::PropColorChanged(Color::Index(9))); assert_ne!( - app.tabs[i].scene.document.get_entity(h).unwrap().common().color, + app.tabs[i] + .scene + .document + .get_entity(h) + .unwrap() + .common() + .color, Color::Index(9), "a locked-layer entity must not be mutated by PropColorChanged" ); @@ -4257,12 +4308,12 @@ mod grip_limit_tests { let handles: Vec<_> = (0..n) .map(|k| { let x = k as f64; - app.tabs[i].scene.add_entity(EntityType::Line( - Line::from_points( + app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( Vector3::new(x, 0.0, 0.0), Vector3::new(x + 1.0, 1.0, 0.0), - ), - )) + ))) }) .collect(); for handle in handles { @@ -4273,11 +4324,17 @@ mod grip_limit_tests { }; assert!( - select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, None) > 0, + select_n( + crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, + None + ) > 0, "a selection at the limit must still show its grips", ); assert_eq!( - select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, None), + select_n( + crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, + None + ), 0, "one past the limit must show none at all", ); @@ -4347,10 +4404,12 @@ mod grip_limit_tests { let handles: Vec<_> = (0..over) .map(|k| { let x = k as f64; - app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(x, 0.0, 0.0), - Vector3::new(x + 1.0, 1.0, 0.0), - ))) + app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 1.0, 0.0), + ))) }) .collect(); for handle in handles { diff --git a/src/app/settings.rs b/src/app/settings.rs index 09f01584a..4607b72d6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -283,10 +283,6 @@ pub struct UserSettings { /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, - /// When true, saving also writes sketch constraints as native drawing - /// objects alongside the application's own persistence record. - #[serde(default)] - pub write_dwg_native_constraints: bool, /// When true (default), a sketch constraint's viewport pill shows its /// glyph plus a driven value or named-parameter name. When false, every /// pill shows just the bare glyph, so the value/name text doesn't cover @@ -418,7 +414,6 @@ impl Default for UserSettings { textfill: true, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7487d5b75..ac9b254cf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -4,13 +4,12 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -59,9 +58,7 @@ fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 fn scale_name_for_factor(scales: &[(String, f64)], factor: f64) -> Option { scales .iter() - .find(|(_, candidate)| { - (*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0) - }) + .find(|(_, candidate)| (*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0)) .map(|(name, _)| name.clone()) } @@ -83,10 +80,7 @@ fn plot_render_mode_override( #[cfg(not(target_arch = "wasm32"))] fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { - match ( - std::fs::canonicalize(left), - std::fs::canonicalize(right), - ) { + match (std::fs::canonicalize(left), std::fs::canonicalize(right)) { (Ok(left), Ok(right)) => left == right, _ => left == right, } @@ -175,7 +169,10 @@ fn plot_content_extents( } for hatch in wipeouts.iter().chain(hatches.iter()) { for &[x, y] in hatch.boundary.iter() { - include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64); + include( + hatch.world_origin[0] + x as f64, + hatch.world_origin[1] + y as f64, + ); } } (bounds.0.is_finite() @@ -243,8 +240,7 @@ fn plot_scene_content( splits, ); } - let (mut model_pattern_wires, model_hatches, model_wipeouts) = - scene.viewport_plot_fills(); + let (mut model_pattern_wires, model_hatches, model_wipeouts) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires .into_iter() @@ -304,9 +300,7 @@ impl OpenCADStudio { for (h, needs_acis, needs_wires) in targets { let body = scene.solid_models.get(&h); let sat = needs_acis - .then(|| { - body.and_then(crate::scene::convert::acis_export::solid_to_sat) - }) + .then(|| body.and_then(crate::scene::convert::acis_export::solid_to_sat)) .flatten(); let wires = needs_wires.then(|| { if let Some(body) = body { @@ -434,7 +428,6 @@ impl OpenCADStudio { textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, - write_dwg_native_constraints: self.write_dwg_native_constraints, show_constraint_values: self.show_constraint_values, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), @@ -516,7 +509,6 @@ impl OpenCADStudio { crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; - self.write_dwg_native_constraints = s.write_dwg_native_constraints; self.show_constraint_values = s.show_constraint_values; self.savetime_min = s.savetime_min; self.default_save_format = @@ -532,9 +524,8 @@ impl OpenCADStudio { .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); self.commandline_fade_ms = crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms); - self.command_line.set_commandline_fade_ms( - self.commandline_fade_ms.clamp(0, 60000) as u32, - ); + self.command_line + .set_commandline_fade_ms(self.commandline_fade_ms.clamp(0, 60000) as u32); // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) self.block_mru = s.block_mru.iter().take(20).cloned().collect(); self.block_freq = s @@ -620,7 +611,11 @@ impl OpenCADStudio { (n, f, m) }) .collect(); - scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0))); + scored.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| a.0.cmp(b.0)) + }); scored.into_iter().map(|(n, _, _)| n.clone()).collect() } @@ -717,8 +712,7 @@ impl OpenCADStudio { /// Rebuild the ribbon's tab list from the registry, dropping the tabs of any /// disabled plugins. Call after `disabled_plugins` changes. pub(in crate::app) fn rebuild_ribbon_modules(&mut self) { - let modules = - crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); + let modules = crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); self.ribbon.set_modules(modules); // Refresh the command-line autocomplete pool so a newly enabled plugin's // commands become typeable (and a disabled one's drop out). This runs on @@ -880,10 +874,10 @@ impl OpenCADStudio { .iter() .map(std::path::PathBuf::from) .collect(); - self.recent_limit = cfg - .recent - .limit - .clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX); + self.recent_limit = cfg.recent.limit.clamp( + crate::app::recent::RECENT_MIN, + crate::app::recent::RECENT_MAX, + ); self.recent_files.truncate(self.recent_limit); self.recent_limit_input = self.recent_limit.to_string(); // Thumbnails are decoded by a background task queued at boot @@ -951,7 +945,8 @@ impl OpenCADStudio { tab.scene.recolor_meshes(); tab.scene.bump_geometry(); } - tab.scene.request_refresh(crate::scene::ViewportRefreshScope::All); + tab.scene + .request_refresh(crate::scene::ViewportRefreshScope::All); } } @@ -962,39 +957,39 @@ impl OpenCADStudio { self.persist_settings_if_changed(); } -pub(super) fn on_open_file(&mut self) -> Task { - // Native: pick a path, then load on a worker thread. Web: the - // browser hands back bytes, so pick + parse in one step and feed - // the shared `FileOpened` handler directly. - #[cfg(not(target_arch = "wasm32"))] - { - Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) - } - #[cfg(target_arch = "wasm32")] - { - // `FileOpened` only installs the result when an open is in - // progress, so mark one. The browser picker + parse happen - // inside `pick_and_load_web`; the real name is unknown until - // then, so show a generic label meanwhile. + pub(super) fn on_open_file(&mut self) -> Task { + // Native: pick a path, then load on a worker thread. Web: the + // browser hands back bytes, so pick + parse in one step and feed + // the shared `FileOpened` handler directly. + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) + } + #[cfg(target_arch = "wasm32")] + { + // `FileOpened` only installs the result when an open is in + // progress, so mark one. The browser picker + parse happen + // inside `pick_and_load_web`; the real name is unknown until + // then, so show a generic label meanwhile. let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); - let open_id = self.next_open_id(); - self.opening = Some(crate::app::OpenProgress { - id: open_id, - name: "Opening…".into(), - source_path: None, - size_bytes: 0, - state: state.clone(), - started: Instant::now(), - recovery_error: None, - recovery_read_stats: None, - recovery_bytes: None, - }); - Task::perform(crate::io::pick_and_load_web(state), move |outcome| { - Message::WebFileOpened(open_id, outcome) - }) - } + let open_id = self.next_open_id(); + self.opening = Some(crate::app::OpenProgress { + id: open_id, + name: "Opening…".into(), + source_path: None, + size_bytes: 0, + state: state.clone(), + started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, + }); + Task::perform(crate::io::pick_and_load_web(state), move |outcome| { + Message::WebFileOpened(open_id, outcome) + }) + } } pub(in crate::app) fn next_open_id(&mut self) -> u64 { @@ -1032,10 +1027,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Read through this session's lease when it covers `path`. #[cfg(not(target_arch = "wasm32"))] - pub(in crate::app) fn read_drawing( - &self, - path: &std::path::Path, - ) -> std::io::Result> { + pub(in crate::app) fn read_drawing(&self, path: &std::path::Path) -> std::io::Result> { use std::io::{Read, Seek, SeekFrom}; let lease = self @@ -1073,8 +1065,7 @@ pub(super) fn on_open_file(&mut self) -> Task { path: &std::path::Path, loaded_fingerprint: Option, ) { - let current_fingerprint = - crate::io::edit_lock::FileFingerprint::capture(path).ok(); + let current_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok(); if loaded_fingerprint .as_ref() .zip(current_fingerprint.as_ref()) @@ -1087,14 +1078,16 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_ref(), ); } - self.tabs[i].disk_fingerprint = - loaded_fingerprint.or(current_fingerprint); + self.tabs[i].disk_fingerprint = loaded_fingerprint.or(current_fingerprint); match crate::io::edit_lock::EditLease::acquire(path) { Ok(lease) => { if let Some(warning) = lease.platform_warning() { - self.command_line.push_info(crate::tf!( - "Edit lease active; {warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!( + "Edit lease active; {warning}. External-change checks remain active." + ) + .as_ref(), + ); } self.tabs[i].edit_lease = Some(lease); self.tabs[i].edit_lock_conflict = false; @@ -1102,16 +1095,16 @@ pub(super) fn on_open_file(&mut self) -> Task { Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => { self.tabs[i].edit_lease = None; self.tabs[i].edit_lock_conflict = true; - self.command_line.push_error_once(crate::tf!( - "Opened read-only against other editors: {error}" - ).as_ref()); + self.command_line.push_error_once( + crate::tf!("Opened read-only against other editors: {error}").as_ref(), + ); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lease = None; self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } } } @@ -1143,27 +1136,29 @@ pub(super) fn on_open_file(&mut self) -> Task { Some(Ok(warning)) => { self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } } Some(Err(crate::io::edit_lock::EditLeaseError::Locked(error))) => { self.tabs[i].edit_lock_conflict = true; - self.command_line.push_error_once(crate::tf!( - "Saved, but the refreshed drawing is locked by another editor: {error}" - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Saved, but the refreshed drawing is locked by another editor: {error}" + ) + .as_ref(), + ); } Some(Err(crate::io::edit_lock::EditLeaseError::Unavailable(error))) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } None => self.install_native_edit_guard(i, path, None), } - self.tabs[i].disk_fingerprint = - crate::io::edit_lock::FileFingerprint::capture(path).ok(); + self.tabs[i].disk_fingerprint = crate::io::edit_lock::FileFingerprint::capture(path).ok(); } #[cfg(not(target_arch = "wasm32"))] @@ -1201,9 +1196,9 @@ pub(super) fn on_open_file(&mut self) -> Task { return Err(crate::io::SaveFailure::file_in_use(error)); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); None } } @@ -1239,12 +1234,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if set_current_path { self.tabs[i].current_path = Some(path.clone()); } - self.refresh_native_edit_guard_after_save( - i, - &path, - path_changed, - destination_lease, - ); + self.refresh_native_edit_guard_after_save(i, &path, path_changed, destination_lease); self.tabs[i].dirty = false; if set_current_path { self.tabs[i].recovery_save_as_required = false; @@ -1254,20 +1244,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(not(target_arch = "wasm32"))] - fn retry_native_edit_guard( - &mut self, - i: usize, - path: &std::path::Path, - ) -> Result<(), String> { + fn retry_native_edit_guard(&mut self, i: usize, path: &std::path::Path) -> Result<(), String> { if let Some(lease) = self.tabs[i].edit_lease.as_mut() { match lease.refresh_drawing_lock(path) { Ok(()) => { let warning = lease.platform_warning().map(str::to_owned); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } return Ok(()); } @@ -1276,9 +1262,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); return Ok(()); } } @@ -1290,75 +1276,84 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].edit_lease = Some(lease); self.tabs[i].edit_lock_conflict = false; if let Some(warning) = warning { - self.command_line.push_info(crate::tf!( - "{warning}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{warning}. External-change checks remain active.").as_ref(), + ); } Ok(()) } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error), Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { self.tabs[i].edit_lock_conflict = false; - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); Ok(()) } } } - pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, + pub(super) fn on_file_opened( + &mut self, + name: String, + path: std::path::PathBuf, + doc: acadrust::CadDocument, mut caches: crate::scene::DerivedCaches, ) -> Task { - // If the user clicked Cancel while the parser was running, the - // overlay state was cleared and we silently drop the result. - if self.opening.is_none() { - return Task::none(); - } - let open_started = self.opening.as_ref().map(|p| p.started); - let size_bytes = self - .opening - .as_ref() - .map(|progress| progress.size_bytes) - .unwrap_or(0); - let timings = caches.timings; - let entity_count = doc.entities().count(); - let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| { - stats.recovered() - || stats.skipped_source_records > 0 - || !stats.stream_completed - }) || doc.notifications.iter().any(|item| { - item.notification_type == acadrust::notification::NotificationType::Error - }); - let reference_recovered = caches - .xrefs - .iter() - .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); - let reference_failed = caches - .xrefs - .iter() - .any(|item| item.status == crate::io::xref::XrefStatus::Failed); - let document_repaired = parser_errors_recovered - || reference_recovered - || caches.corrupt_dropped > 0 - || caches.xref_dropped > 0; - let recovery_needed = document_repaired || reference_failed; - let total_ms = open_started - .map(|started| started.elapsed().as_millis() as u32) - .unwrap_or(0); - self.command_line - .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); - if caches.corrupt_dropped > 0 { - self.command_line.push_error(crate::tf!( - "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", - caches.corrupt_dropped - ).as_ref()); - } + // If the user clicked Cancel while the parser was running, the + // overlay state was cleared and we silently drop the result. + if self.opening.is_none() { + return Task::none(); + } + let open_started = self.opening.as_ref().map(|p| p.started); + let size_bytes = self + .opening + .as_ref() + .map(|progress| progress.size_bytes) + .unwrap_or(0); + let timings = caches.timings; + let entity_count = doc.entities().count(); + let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| { + stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed + }) || doc + .notifications + .iter() + .any(|item| item.notification_type == acadrust::notification::NotificationType::Error); + let reference_recovered = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); + let reference_failed = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Failed); + let document_repaired = parser_errors_recovered + || reference_recovered + || caches.corrupt_dropped > 0 + || caches.xref_dropped > 0; + let recovery_needed = document_repaired || reference_failed; + let total_ms = open_started + .map(|started| started.elapsed().as_millis() as u32) + .unwrap_or(0); + self.command_line + .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); + if caches.corrupt_dropped > 0 { + self.command_line.push_error( + crate::tf!( + "Warning: {} corrupt entities dropped (parser junk — bad normals / counts)", + caches.corrupt_dropped + ) + .as_ref(), + ); + } if caches.xref_dropped > 0 { - self.command_line.push_error(crate::tf!( - "Warning: {} corrupt xref entities dropped", - caches.xref_dropped - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Warning: {} corrupt xref entities dropped", + caches.xref_dropped + ) + .as_ref(), + ); } for info in &caches.xrefs { match info.status { @@ -1367,341 +1362,345 @@ pub(super) fn on_open_file(&mut self) -> Task { .push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref()); } crate::io::xref::XrefStatus::Recovered => { - self.command_line.push_error(crate::tf!( - "XREF Recovered with warnings: \"{}\"", - info.name - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(), + ); } crate::io::xref::XrefStatus::NotFound => { - self.command_line.push_error(crate::tf!( - "XREF Not found: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(), + ); } crate::io::xref::XrefStatus::Failed => { - self.command_line.push_error(crate::tf!( - "XREF Recovery failed: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path) + .as_ref(), + ); } crate::io::xref::XrefStatus::Unloaded => { - self.command_line - .push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref()); + self.command_line.push_info( + crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref(), + ); } } } - #[cfg(not(target_arch = "wasm32"))] - let thumbs_task = self.push_recent(path.clone()); - #[cfg(target_arch = "wasm32")] - let thumbs_task = Task::none(); + #[cfg(not(target_arch = "wasm32"))] + let thumbs_task = self.push_recent(path.clone()); + #[cfg(target_arch = "wasm32")] + let thumbs_task = Task::none(); - let current_is_empty = { - let t = &self.tabs[self.active_tab]; - !t.is_start - && t.current_path.is_none() - && !t.dirty - && self.tabs[self.active_tab].scene.document.entities().count() == 0 - }; - let i = if current_is_empty { - self.active_tab - } else { - self.tab_counter += 1; - let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter); - self.tabs.push(new_tab); - let idx = self.tabs.len() - 1; - self.active_tab = idx; - self.apply_display_defaults(idx); - idx - }; + let current_is_empty = { + let t = &self.tabs[self.active_tab]; + !t.is_start + && t.current_path.is_none() + && !t.dirty + && self.tabs[self.active_tab].scene.document.entities().count() == 0 + }; + let i = if current_is_empty { + self.active_tab + } else { + self.tab_counter += 1; + let new_tab = crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.tabs.push(new_tab); + let idx = self.tabs.len() - 1; + self.active_tab = idx; + self.apply_display_defaults(idx); + idx + }; - let mut recovery_report = recovery_needed.then(|| { - crate::io::recovery::RecoveryReport::recovered( - self.tabs[i].id, - &path, - size_bytes, - caches.source_sha256.clone(), - caches.read_stats.clone(), - entity_count.saturating_add(caches.corrupt_dropped), - caches.corrupt_dropped, - caches.xref_dropped, - &caches.xrefs, - &doc.notifications, - document_repaired, - timings, - total_ms, - ) - }); - if let Some(report) = recovery_report.as_mut() { - report.persist(); - } + let mut recovery_report = recovery_needed.then(|| { + crate::io::recovery::RecoveryReport::recovered( + self.tabs[i].id, + &path, + size_bytes, + caches.source_sha256.clone(), + caches.read_stats.clone(), + entity_count.saturating_add(caches.corrupt_dropped), + caches.corrupt_dropped, + caches.xref_dropped, + &caches.xrefs, + &doc.notifications, + document_repaired, + timings, + total_ms, + ) + }); + if let Some(report) = recovery_report.as_mut() { + report.persist(); + } - #[cfg(not(target_arch = "wasm32"))] - let opened_fingerprint = self - .opening - .as_ref() - .and_then(|opening| opening.fingerprint.clone()); - self.tabs[i].current_path = Some(path.clone()); - #[cfg(not(target_arch = "wasm32"))] - self.install_native_edit_guard(i, &path, opened_fingerprint); - self.tabs[i].scene.material_base_dir = - path.parent().map(std::path::Path::to_path_buf); - self.tabs[i].scene.document = doc; - // Load parameters first so imported dimensional constraints - // can resolve their named driving values. - self.tabs[i].scene.load_named_parameters_from_document(); - self.tabs[i].scene.load_sketch_constraints_from_document(); - self.tabs[i].active_layer = self.tabs[i] - .scene - .document - .header - .current_layer_name - .clone(); - // A file saved without the built-in Standard styles (foreign - // or damaged) gets them re-seeded so nothing dangles (#366). - crate::app::style_ops::ensure_standard_styles( - &mut self.tabs[i].scene.document, - ); - // Follow the file's saved current UCS from the moment it opens. - self.tabs[i].adopt_active_ucs_from_header(); - // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP - // ($OSMODE) so each file keeps its own state. - self.adopt_header_sysvars(i); - // Route shared CJK ideographs to the language matching this - // drawing's code page (web per-language font split). Drop the - // glyph cache if it changed so Han re-resolves to the new - // language's font; geometry is (re)built below regardless. (#141) - if crate::scene::text::web_font::set_cjk_lang_from_codepage( - &self.tabs[i].scene.document.header.code_page, - ) { - crate::scene::text::ttf_glyph::clear_fallback_cache(); - } - // Current model-space annotation scale comes from the drawing's - // CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into - // drawing units as well: metric annotation sizes are paper millimetres - // and imperial annotation sizes are paper inches. - // Current model-space annotation scale comes from the drawing's - // CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into - // the drawing's INSUNITS as well, so e.g. a metre drawing uses - // 0.001 model units for 1 mm of paper at 1:1. - let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; - let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor(); + #[cfg(not(target_arch = "wasm32"))] + let opened_fingerprint = self + .opening + .as_ref() + .and_then(|opening| opening.fingerprint.clone()); + self.tabs[i].current_path = Some(path.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.install_native_edit_guard(i, &path, opened_fingerprint); + self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); + self.tabs[i].scene.document = doc; + // Load parameters first so imported dimensional constraints + // can resolve their named driving values. + self.tabs[i].scene.load_named_parameters_from_document(); + self.tabs[i] + .scene + .load_parametric_constraints_from_document(); + self.tabs[i].active_layer = self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(); + // A file saved without the built-in Standard styles (foreign + // or damaged) gets them re-seeded so nothing dangles (#366). + crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document); + // Follow the file's saved current UCS from the moment it opens. + self.tabs[i].adopt_active_ucs_from_header(); + // Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP + // ($OSMODE) so each file keeps its own state. + self.adopt_header_sysvars(i); + // Route shared CJK ideographs to the language matching this + // drawing's code page (web per-language font split). Drop the + // glyph cache if it changed so Han re-resolves to the new + // language's font; geometry is (re)built below regardless. (#141) + if crate::scene::text::web_font::set_cjk_lang_from_codepage( + &self.tabs[i].scene.document.header.code_page, + ) { + crate::scene::text::ttf_glyph::clear_fallback_cache(); + } + // Current model-space annotation scale comes from the drawing's + // CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into + // drawing units as well: metric annotation sizes are paper millimetres + // and imperial annotation sizes are paper inches. + // Current model-space annotation scale comes from the drawing's + // CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into + // the drawing's INSUNITS as well, so e.g. a metre drawing uses + // 0.001 model units for 1 mm of paper at 1:1. + let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value; + let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor(); - self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { - ((1.0 / cannoscale_value) / unit_factor) as f32 - } else { - (1.0 / unit_factor) as f32 - }; + self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 { + ((1.0 / cannoscale_value) / unit_factor) as f32 + } else { + (1.0 / unit_factor) as f32 + }; - // Open-time breakdown so regressions are visible immediately. - // `total` is wall time from the Open click to here (post-xref, - // pre-first-frame); the phase figures are the background-thread - // parse/purge/cache spans plus the UI-thread xref resolve. - self.command_line.push_info(crate::tf!( - " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", - timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms - ).as_ref()); + // Open-time breakdown so regressions are visible immediately. + // `total` is wall time from the Open click to here (post-xref, + // pre-first-frame); the phase figures are the background-thread + // parse/purge/cache spans plus the UI-thread xref resolve. + self.command_line.push_info( + crate::tf!( + " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", + timings.parse_ms, + timings.purge_ms, + timings.caches_ms, + timings.xref_ms, + total_ms + ) + .as_ref(), + ); - // Caches were built on the background thread inside open_path(). - self.tabs[i].scene.local_extent_max = caches.local_extent_max; - self.tabs[i].scene.local_center = caches.local_center; - self.tabs[i].scene.hatches = caches.hatches; - self.tabs[i].scene.images = caches.images; - self.tabs[i].scene.meshes = caches.meshes; - self.tabs[i].scene.block_meshes = caches.block_meshes; - self.tabs[i].scene.object_data_cache = caches.object_data; + // Caches were built on the background thread inside open_path(). + self.tabs[i].scene.local_extent_max = caches.local_extent_max; + self.tabs[i].scene.local_center = caches.local_center; + self.tabs[i].scene.hatches = caches.hatches; + self.tabs[i].scene.images = caches.images; + self.tabs[i].scene.meshes = caches.meshes; + self.tabs[i].scene.block_meshes = caches.block_meshes; + self.tabs[i].scene.object_data_cache = caches.object_data; let prepared_geometry = caches.prepared_geometry.take(); - // Invalidate the wire cache so the new document is tessellated. - self.tabs[i].scene.bump_geometry(); - if let Some(prepared) = prepared_geometry { - self.tabs[i].scene.install_prepared_open_geometry(prepared); - } - // Pay for the interaction index here, not on the first hover. - self.tabs[i].scene.warm_interaction_caches(); - self.tabs[i] - .scene - .replace_selection(rustc_hash::FxHashSet::default()); - self.tabs[i].scene.preview_wires = vec![]; - // Reopen in whichever space the file was saved in — the CTAB - // tab name when recorded, else the $TILEMODE model/paper flag — - // instead of always landing in Model. - { - let names = self.tabs[i].scene.layout_names(); - let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document) - .and_then(|n| { - names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned() - }); - self.tabs[i].scene.current_layout = match saved { - Some(n) => n, - None if self.tabs[i].scene.document.header.show_model_space => { - "Model".to_string() - } - // Paper was active but no CTAB — fall back to the first - // paper layout (names[0] is always "Model"). - None => names - .into_iter() - .nth(1) - .unwrap_or_else(|| "Model".to_string()), - }; - self.tabs[i].scene.load_current_layout_state(); - self.tabs[i].refresh_active_ucs(); - } - // Object isolation is session-only. A newly opened drawing must - // not inherit the previous tab's filter, and persisted entity - // visibility remains independent (not an isolation session). - self.tabs[i].scene.reset_transient_visibility(); - crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); - self.tabs[i].properties = PropertiesPanel::empty(); - // Seed the current table / multileader style from the file's - // header so the ✓ marks the right one (text/dim/mline come from - // the document header directly). DXF provides these via - // $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard". - self.ribbon.active_table_style = self.tabs[i] - .scene - .document - .header - .current_table_style_name - .clone(); - self.tabs[i].active_mleader_style = self.tabs[i] - .scene - .document - .header - .current_mleader_style_name - .clone(); - let doc_layers = self.tabs[i].scene.document.layers.clone(); - let vp_info = self.tabs[i].scene.viewport_list(); - self.tabs[i] - .layers - .sync_with_viewports(&doc_layers, vp_info); - self.sync_ribbon_layers(); - // Load the Annotate-ribbon style dropdowns (text / dimension / - // multileader / table) from the opened document instead of - // leaving them on the hard-coded "Standard" default. - self.sync_ribbon_styles(); - // Reset the Home-ribbon Color / Linetype / Lineweight chips - // to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT - // defaults (or to ByLayer when the file leaves them empty). - // Without this they stick to whatever the prior tab had - // selected — see #21. - self.sync_ribbon_from_selection(); - self.tabs[i].scene.restore_saved_camera(); - // Grid/snap are per-drawing view settings — adopt the opened - // file's active viewport state rather than a global preference. - self.adopt_view_display(i); - self.sync_render_mode_to_active_tile(i); - self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; - self.tabs[i].dirty = document_repaired; - self.tabs[i].recovery_save_as_required = document_repaired; - self.tabs[i].history = crate::app::document::HistoryState::default(); - self.refresh_properties(); - #[cfg(not(target_arch = "wasm32"))] - let interaction_task = { - let wires = self.tabs[i].scene.hit_test_wires(); - let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; - self.prepare_interaction_index_task(i, wires, screen_height) - .unwrap_or_else(Task::none) - }; - #[cfg(target_arch = "wasm32")] - let interaction_task = Task::none(); + // Invalidate the wire cache so the new document is tessellated. + self.tabs[i].scene.bump_geometry(); + if let Some(prepared) = prepared_geometry { + self.tabs[i].scene.install_prepared_open_geometry(prepared); + } + // Pay for the interaction index here, not on the first hover. + self.tabs[i].scene.warm_interaction_caches(); + self.tabs[i] + .scene + .replace_selection(rustc_hash::FxHashSet::default()); + self.tabs[i].scene.preview_wires = vec![]; + // Reopen in whichever space the file was saved in — the CTAB + // tab name when recorded, else the $TILEMODE model/paper flag — + // instead of always landing in Model. + { + let names = self.tabs[i].scene.layout_names(); + let saved = crate::io::saved_active_layout(&self.tabs[i].scene.document) + .and_then(|n| names.iter().find(|x| x.eq_ignore_ascii_case(&n)).cloned()); + self.tabs[i].scene.current_layout = match saved { + Some(n) => n, + None if self.tabs[i].scene.document.header.show_model_space => "Model".to_string(), + // Paper was active but no CTAB — fall back to the first + // paper layout (names[0] is always "Model"). + None => names + .into_iter() + .nth(1) + .unwrap_or_else(|| "Model".to_string()), + }; + self.tabs[i].scene.load_current_layout_state(); + self.tabs[i].refresh_active_ucs(); + } + // Object isolation is session-only. A newly opened drawing must + // not inherit the previous tab's filter, and persisted entity + // visibility remains independent (not an isolation session). + self.tabs[i].scene.reset_transient_visibility(); + crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); + self.tabs[i].properties = PropertiesPanel::empty(); + // Seed the current table / multileader style from the file's + // header so the ✓ marks the right one (text/dim/mline come from + // the document header directly). DXF provides these via + // $CTABLESTYLE / $CMLEADERSTYLE; DWG leaves them at "Standard". + self.ribbon.active_table_style = self.tabs[i] + .scene + .document + .header + .current_table_style_name + .clone(); + self.tabs[i].active_mleader_style = self.tabs[i] + .scene + .document + .header + .current_mleader_style_name + .clone(); + let doc_layers = self.tabs[i].scene.document.layers.clone(); + let vp_info = self.tabs[i].scene.viewport_list(); + self.tabs[i] + .layers + .sync_with_viewports(&doc_layers, vp_info); + self.sync_ribbon_layers(); + // Load the Annotate-ribbon style dropdowns (text / dimension / + // multileader / table) from the opened document instead of + // leaving them on the hard-coded "Standard" default. + self.sync_ribbon_styles(); + // Reset the Home-ribbon Color / Linetype / Lineweight chips + // to the newly opened document's CECOLOR / CELTYPE / CELWEIGHT + // defaults (or to ByLayer when the file leaves them empty). + // Without this they stick to whatever the prior tab had + // selected — see #21. + self.sync_ribbon_from_selection(); + self.tabs[i].scene.restore_saved_camera(); + // Grid/snap are per-drawing view settings — adopt the opened + // file's active viewport state rather than a global preference. + self.adopt_view_display(i); + self.sync_render_mode_to_active_tile(i); + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; + self.tabs[i].dirty = document_repaired; + self.tabs[i].recovery_save_as_required = document_repaired; + self.tabs[i].history = crate::app::document::HistoryState::default(); + self.refresh_properties(); + #[cfg(not(target_arch = "wasm32"))] + let interaction_task = { + let wires = self.tabs[i].scene.hit_test_wires(); + let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; + self.prepare_interaction_index_task(i, wires, screen_height) + .unwrap_or_else(Task::none) + }; + #[cfg(target_arch = "wasm32")] + let interaction_task = Task::none(); if let Some(opening) = &self.opening { opening .state .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); - let pending_open_task = if let Some(report) = recovery_report { - self.recovery_report = Some(report); - self.active_modal = Some(crate::app::ModalKind::Recovery); - Task::none() - } else { - self.drain_pending_open() - }; - Task::batch([thumbs_task, pending_open_task, interaction_task]) + let pending_open_task = if let Some(report) = recovery_report { + self.recovery_report = Some(report); + self.active_modal = Some(crate::app::ModalKind::Recovery); + Task::none() + } else { + self.drain_pending_open() + }; + Task::batch([thumbs_task, pending_open_task, interaction_task]) } - pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf, + pub(super) fn on_wblock_save_result_some( + &mut self, + block_name: String, + path: std::path::PathBuf, ) -> Task { - let i = self.active_tab; - let document = self.tabs[i].scene.document.clone(); - let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + let i = self.active_tab; + let document = self.tabs[i].scene.document.clone(); + let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); let worker_name = block_name.clone(); let worker_path = path.clone(); background_task( move || { let document = if worker_name == "*" { - crate::modules::insert::wblock::extract_entities_to_doc( - &document, - &handles, - ) + crate::modules::insert::wblock::extract_entities_to_doc(&document, &handles) } else { - crate::modules::insert::wblock::extract_block_to_doc( - &document, - &worker_name) + crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) } - .map_err(|e| e.to_string())?; crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) - }, - move |result| Message::WblockWriteFinished(block_name, path, result),) + .map_err(|e| e.to_string())?; + crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + }, + move |result| Message::WblockWriteFinished(block_name, path, result), + ) } pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { - // Re-build STL bytes (we can't easily pass them through the message). - let i = self.active_tab; - // STL gets the highest-resolution LOD (slot 0) so the - // exported geometry isn't downgraded by the view-dependent - // mesh LOD ladder used for rendering. - let meshes: Vec = self.tabs[i] - .scene - .meshes - .values() - .filter_map(|s| s.lods.first().cloned()) - .collect(); - let worker_path = path.clone(); + // Re-build STL bytes (we can't easily pass them through the message). + let i = self.active_tab; + // STL gets the highest-resolution LOD (slot 0) so the + // exported geometry isn't downgraded by the view-dependent + // mesh LOD ladder used for rendering. + let meshes: Vec = self.tabs[i] + .scene + .meshes + .values() + .filter_map(|s| s.lods.first().cloned()) + .collect(); + let worker_path = path.clone(); background_task( move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let bytes = crate::io::stl::build_stl(&mesh_refs) .ok_or_else(|| "no mesh data to export".to_string())?; std::fs::write(&worker_path, bytes).map_err(|e| e.to_string()) - }, - move |result| Message::StlExportFinished(path, result),) + }, + move |result| Message::StlExportFinished(path, result), + ) } pub(super) fn on_step_export_path_some(&mut self, path: std::path::PathBuf) -> Task { - let i = self.active_tab; - // Export uses LOD 0 (full resolution); see StlExportPath above. - let meshes: Vec = self.tabs[i] - .scene - .meshes - .values() - .filter_map(|s| s.lods.first().cloned()) - .collect(); - let worker_path = path.clone(); + let i = self.active_tab; + // Export uses LOD 0 (full resolution); see StlExportPath above. + let meshes: Vec = self.tabs[i] + .scene + .meshes + .values() + .filter_map(|s| s.lods.first().cloned()) + .collect(); + let worker_path = path.clone(); background_task( move || { let mesh_refs: Vec<_> = meshes.iter().collect(); let text = crate::io::step::build_step(&mesh_refs) .ok_or_else(|| "no mesh data to export".to_string())?; std::fs::write(&worker_path, text.as_bytes()).map_err(|e| e.to_string()) - }, - move |result| Message::StepExportFinished(path, result),) + }, + move |result| Message::StepExportFinished(path, result), + ) } pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task { - let tab_id = self.tabs[self.active_tab].id; + let tab_id = self.tabs[self.active_tab].id; let worker_path = path.clone(); background_task( move || { - let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) - .ok_or_else(|| "no usable geometry in file".to_string()) + let src = std::fs::read_to_string(&worker_path).map_err(|e| e.to_string())?; + crate::io::obj::parse_obj(&src, [0.7, 0.7, 0.85, 1.0]) + .ok_or_else(|| "no usable geometry in file".to_string()) }, - move |result| Message::ObjImportFinished(tab_id, path, result),) + move |result| Message::ObjImportFinished(tab_id, path, result), + ) } fn sync_view_state_for_save(&mut self, i: usize) { self.sync_vport_display(i); if self.tabs[i].active_block_edit.is_none() { self.tabs[i].scene.sync_camera_to_document(); - self.tabs[i].last_synced_camera_gen = - self.tabs[i].scene.camera_generation; + self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; } } @@ -1710,16 +1709,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); - self.tabs[i].scene.document.header.user_real1 = - self.tabs[i].scene.annotation_scale as f64; + self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; self.sync_solid_models_for_save(i); - // Materialize constraints immediately before serialization. - self.tabs[i].scene.materialize_sketch_constraints_for_save(); - // named_parameters_design.md stage 2: same save-time hook, for the - // document-wide parameter table. - self.tabs[i].scene.materialize_named_parameters_for_save(); - // Keep the optional native constraint graph synchronized on save. - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); + self.tabs[i].scene.sync_native_parametric_graph(); } #[cfg(not(target_arch = "wasm32"))] @@ -1836,11 +1828,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } let mut recent_task = Task::none(); - let saved = match crate::io::save_to_bytes( - &self.tabs[i].scene.document, - &ext, - version, - ) { + let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); let cache_name = std::path::Path::new(&filename) @@ -1928,13 +1916,11 @@ pub(super) fn on_open_file(&mut self) -> Task { && self.tabs[i].recovery_save_as_required && destination_is_current { - self.command_line.push_error_once( - crate::tr!("recovery", "save-new-file-required").as_ref(), - ); + self.command_line + .push_error_once(crate::tr!("recovery", "save-new-file-required").as_ref()); self.restore_failed_save_continuation(continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None; if capture_ready { self.thumbnail_capture_clean = false; } @@ -1945,12 +1931,15 @@ pub(super) fn on_open_file(&mut self) -> Task { && self.tabs[i].edit_lock_conflict { let error = "Drawing edit lock is held by another editor.".to_string(); - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{}\": {error}", - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{}\": {error}", + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id, path, @@ -1972,22 +1961,18 @@ pub(super) fn on_open_file(&mut self) -> Task { && i == self.active_tab && !self.thumbnail_capture_clean && self.main_window.is_some() - && crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ) - .is_some() + && crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) + .is_some() { - self.pending_native_thumbnail_save = Some( - crate::app::PendingNativeThumbnailSave { - tab_id, - path, - version, - purpose, - continuation, - set_current_path, - check_external_change, - }, - ); + self.pending_native_thumbnail_save = Some(crate::app::PendingNativeThumbnailSave { + tab_id, + path, + version, + purpose, + continuation, + set_current_path, + check_external_change, + }); self.thumbnail_capture_clean = true; return Task::none(); } @@ -1998,12 +1983,15 @@ pub(super) fn on_open_file(&mut self) -> Task { self.pending_save_leases.insert(tab_id, lease); } Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => { - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{}\": {error}", - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()) - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{}\": {error}", + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id, path, @@ -2021,9 +2009,9 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { - self.command_line.push_info(crate::tf!( - "{error}. External-change checks remain active." - ).as_ref()); + self.command_line.push_info( + crate::tf!("{error}. External-change checks remain active.").as_ref(), + ); } } } @@ -2034,9 +2022,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab) .then_some(version >= acadrust::DxfVersion::AC1027); let capture_bounds = thumbnail.and_then(|_| { - crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ) + crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); let snapshot = self.tabs[i].scene.document.clone(); @@ -2056,8 +2042,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let previous_autosave = (purpose != crate::app::SavePurpose::Autosave).then(|| self.autosave_target(i)); let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save; - let verification = if check_external_change - && purpose != crate::app::SavePurpose::Autosave + let verification = if check_external_change && purpose != crate::app::SavePurpose::Autosave { let expected = if set_current_path { None @@ -2071,11 +2056,7 @@ pub(super) fn on_open_file(&mut self) -> Task { expected, ) } else if destination_is_current { - Self::native_save_verification( - &path, - self.tabs[i].edit_lease.as_mut(), - expected, - ) + Self::native_save_verification(&path, self.tabs[i].edit_lease.as_mut(), expected) } else { Self::native_save_verification(&path, None, expected) } @@ -2132,7 +2113,9 @@ pub(super) fn on_open_file(&mut self) -> Task { verify_reader, path, previous_autosave, - ) = work.take().expect("save capture produced more than one result"); + ) = work + .take() + .expect("save capture produced more than one result"); Task::perform( async move { let (result, refreshed_preview) = std::thread::spawn(move || { @@ -2191,12 +2174,14 @@ pub(super) fn on_open_file(&mut self) -> Task { ) }; match capture_window { - Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| { - Task::batch([ - Task::done(Message::ThumbnailCaptureFinished), - run_save(screenshot), - ]) - }), + Some(window) => iced::window::screenshot(window) + .map(Some) + .then(move |screenshot| { + Task::batch([ + Task::done(Message::ThumbnailCaptureFinished), + run_save(screenshot), + ]) + }), None => { self.thumbnail_capture_clean = false; run_save(None) @@ -2205,12 +2190,8 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(not(target_arch = "wasm32"))] - pub(super) fn on_save_finished( - &mut self, - outcome: crate::app::SaveOutcome, - ) -> Task { - let latest = self.active_save_jobs.get(&outcome.tab_id).copied() - == Some(outcome.job_id); + pub(super) fn on_save_finished(&mut self, outcome: crate::app::SaveOutcome) -> Task { + let latest = self.active_save_jobs.get(&outcome.tab_id).copied() == Some(outcome.job_id); if latest { self.active_save_jobs.remove(&outcome.tab_id); } @@ -2230,17 +2211,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } if let Err(error) = &outcome.result { - if error.externally_modified - && outcome.purpose != crate::app::SavePurpose::Autosave - { + if error.externally_modified && outcome.purpose != crate::app::SavePurpose::Autosave { let file_name = outcome .path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| outcome.path.display().to_string()); - self.command_line.push_error_once(crate::tf!( - "Save stopped: \"{file_name}\" changed outside Open CAD Studio." - ).as_ref()); + self.command_line.push_error_once( + crate::tf!("Save stopped: \"{file_name}\" changed outside Open CAD Studio.") + .as_ref(), + ); self.pending_external_change = Some(crate::app::PendingExternalChange { tab_id: outcome.tab_id, path: outcome.path.clone(), @@ -2259,9 +2239,12 @@ pub(super) fn on_open_file(&mut self) -> Task { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| outcome.path.display().to_string()); - self.command_line.push_error_once(crate::tf!( - "Unable to save \"{file_name}\": file is in use by another application." - ).as_ref()); + self.command_line.push_error_once( + crate::tf!( + "Unable to save \"{file_name}\": file is in use by another application." + ) + .as_ref(), + ); self.pending_save_failure = Some(crate::app::PendingSaveFailure { tab_id: outcome.tab_id, path: outcome.path.clone(), @@ -2309,16 +2292,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let mut tasks = Vec::new(); match outcome.purpose { crate::app::SavePurpose::Autosave => { - self.command_line.push_output(crate::t!("Autosaved 1 drawing").as_ref()); + self.command_line + .push_output(crate::t!("Autosaved 1 drawing").as_ref()); } crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => { let path_changed = outcome.set_current_path && self.tabs[i] .current_path .as_deref() - .is_none_or(|current| { - !native_paths_match(current, &outcome.path) - }); + .is_none_or(|current| !native_paths_match(current, &outcome.path)); self.command_line .push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref()); if let Some(previous) = outcome.previous_autosave { @@ -2384,9 +2366,7 @@ pub(super) fn on_open_file(&mut self) -> Task { ) { self.pending_close = match continuation { crate::app::SaveContinuation::None => None, - crate::app::SaveContinuation::CloseTab => { - Some(crate::app::PendingClose::Tab(tab_idx)) - } + crate::app::SaveContinuation::CloseTab => Some(crate::app::PendingClose::Tab(tab_idx)), crate::app::SaveContinuation::Quit => Some(crate::app::PendingClose::Quit), }; } @@ -2441,8 +2421,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); self.restore_failed_save_continuation(failure.continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - failure.continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = failure.continuation != crate::app::SaveContinuation::None; self.open_save_dialog_window(i) } @@ -2484,8 +2463,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); self.restore_failed_save_continuation(conflict.continuation, i); self.active_tab = i; - self.save_dialog_for_unsaved = - conflict.continuation != crate::app::SaveContinuation::None; + self.save_dialog_for_unsaved = conflict.continuation != crate::app::SaveContinuation::None; self.open_save_dialog_window(i) } @@ -2521,40 +2499,41 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_save_file(&mut self) -> Task { - if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); - return Task::none(); - } - let i = self.active_tab; - // Web serializes immediately below. Native preparation happens - // once after the destination/version is known. - #[cfg(target_arch = "wasm32")] - { - self.sync_view_state_for_save(i); - self.stamp_header_sysvars(i); - } - // Native: save straight to the known path. Web has no path - // (downloads instead), so always go through the Save dialog. - #[cfg(not(target_arch = "wasm32"))] - if !self.tabs[i].recovery_save_as_required { - if let Some(path) = self.tabs[i].current_path.clone() { - // A direct Save preserves the document's current version. - let ver = self.tabs[i].scene.document.version; - self.prepare_native_save(i); - return self.queue_native_save( - i, - path, - ver, - crate::app::SavePurpose::Manual, - crate::app::SaveContinuation::None, - false, - true, - ); - } - } - self.save_dialog_for_unsaved = false; - self.save_with_default_format(i) + if self.read_only { + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); + return Task::none(); + } + let i = self.active_tab; + // Web serializes immediately below. Native preparation happens + // once after the destination/version is known. + #[cfg(target_arch = "wasm32")] + { + self.sync_view_state_for_save(i); + self.stamp_header_sysvars(i); + } + // Native: save straight to the known path. Web has no path + // (downloads instead), so always go through the Save dialog. + #[cfg(not(target_arch = "wasm32"))] + if !self.tabs[i].recovery_save_as_required { + if let Some(path) = self.tabs[i].current_path.clone() { + // A direct Save preserves the document's current version. + let ver = self.tabs[i].scene.document.version; + self.prepare_native_save(i); + return self.queue_native_save( + i, + path, + ver, + crate::app::SavePurpose::Manual, + crate::app::SaveContinuation::None, + false, + true, + ); + } + } + self.save_dialog_for_unsaved = false; + self.save_with_default_format(i) } /// Save without the version picker: use the configured default and go straight to @@ -2565,10 +2544,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_tab = tab_idx; self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required { let document = &self.tabs[tab_idx].scene.document; - let is_dxf = crate::io::source_is_dxf( - self.tabs[tab_idx].current_path.as_deref(), - document, - ); + let is_dxf = + crate::io::source_is_dxf(self.tabs[tab_idx].current_path.as_deref(), document); let version = if is_dxf { document.version } else { @@ -2598,110 +2575,104 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_save_dialog_confirm(&mut self) -> Task { - let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); - // Warn before a lossy Save-As that would drop unsupported - // (AEC / application) objects kept only as verbatim - // source-version bytes — let the user keep them by saving in the - // source version, or proceed and drop them. - if !self.aec_drop_acknowledged { - let is_dxf = ext.eq_ignore_ascii_case("dxf"); - let n = crate::io::dropped_on_save_count( - &self.tabs[self.active_tab].scene.document, - version, - is_dxf, - ); - if n > 0 { - self.aec_drop_count = n; - self.active_modal = Some(crate::app::ModalKind::AecDropWarning); - return Task::none(); - } - } - // The user need not type an extension: append the selected - // format's one when the entered name carries none. - let name = self.save_dialog_filename.trim(); - let filename = if name.is_empty() { - format!("drawing.{ext}") - } else if std::path::Path::new(name).extension().is_none() { - format!("{name}.{ext}") - } else { - name.to_string() - }; - self.save_dialog_filename = filename.clone(); - let i = self.active_tab; + let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); + // Warn before a lossy Save-As that would drop unsupported + // (AEC / application) objects kept only as verbatim + // source-version bytes — let the user keep them by saving in the + // source version, or proceed and drop them. + if !self.aec_drop_acknowledged { + let is_dxf = ext.eq_ignore_ascii_case("dxf"); + let n = crate::io::dropped_on_save_count( + &self.tabs[self.active_tab].scene.document, + version, + is_dxf, + ); + if n > 0 { + self.aec_drop_count = n; + self.active_modal = Some(crate::app::ModalKind::AecDropWarning); + return Task::none(); + } + } + // The user need not type an extension: append the selected + // format's one when the entered name carries none. + let name = self.save_dialog_filename.trim(); + let filename = if name.is_empty() { + format!("drawing.{ext}") + } else if std::path::Path::new(name).extension().is_none() { + format!("{name}.{ext}") + } else { + name.to_string() + }; + self.save_dialog_filename = filename.clone(); + let i = self.active_tab; - // Native: hand the destination choice to the OS save dialog — - // it provides folder browsing and overwrite confirmation. The - // chosen version rides in `save_dialog_format` and the write - // happens in `on_save_dialog_path_picked` once a path returns. - #[cfg(not(target_arch = "wasm32"))] - { - let seed_dir = self.tabs[i] - .current_path - .as_ref() - .and_then(|p| p.parent().map(|d| d.to_path_buf())); - let default_name = filename; - let (filter_label, filter_ext): (&str, &str) = - if ext.eq_ignore_ascii_case("dxf") { - ("DXF Files", "dxf") - } else { - ("DWG Files", "dwg") - }; - let close = self.close_save_dialog_window(); - let pick = Task::perform( - async move { - let mut dlg = crate::sys::file_dialog() - .set_title(crate::t!("Save Drawing As").as_ref()) - .set_file_name(default_name) - .add_filter(filter_label, &[filter_ext]); - if let Some(dir) = seed_dir { - dlg = dlg.set_directory(dir); - } - dlg.save_file().await.map(|h| crate::sys::handle_path(&h)) - }, - Message::SaveDialogPathPicked, - ); - Task::batch([close, pick]) - } - // Web: no filesystem — serialize and hand the browser a download. - #[cfg(target_arch = "wasm32")] - { - let close = self.close_save_dialog_window(); - self.sync_view_state_for_save(i); - sync_annotation_scale_header(&mut self.tabs[i].scene); - self.stamp_header_sysvars(i); - self.sync_solid_models_for_save(i); - self.tabs[i].scene.materialize_sketch_constraints_for_save(); - self.tabs[i].scene.materialize_named_parameters_for_save(); - self.tabs[i].scene.materialize_dwg_native_constraints_for_save(self.write_dwg_native_constraints); - let tab_id = self.tabs[i].id; - let bounds = crate::ui::wrap_bar::dropdown_bounds( - crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, - ); - let Some(bounds) = bounds else { - return Task::batch([ - close, - Task::done(Message::WebSaveScreenshot { - tab_id, - filename, - ext: ext.to_string(), - version, - bounds: None, - screenshot: None, - }), - ]); - }; - self.pending_web_thumbnail_save = Some( - crate::app::PendingWebThumbnailSave { - tab_id, - filename, - ext: ext.to_string(), - version, - bounds, - }, - ); - self.thumbnail_capture_clean = true; - close - } + // Native: hand the destination choice to the OS save dialog — + // it provides folder browsing and overwrite confirmation. The + // chosen version rides in `save_dialog_format` and the write + // happens in `on_save_dialog_path_picked` once a path returns. + #[cfg(not(target_arch = "wasm32"))] + { + let seed_dir = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let default_name = filename; + let (filter_label, filter_ext): (&str, &str) = if ext.eq_ignore_ascii_case("dxf") { + ("DXF Files", "dxf") + } else { + ("DWG Files", "dwg") + }; + let close = self.close_save_dialog_window(); + let pick = Task::perform( + async move { + let mut dlg = crate::sys::file_dialog() + .set_title(crate::t!("Save Drawing As").as_ref()) + .set_file_name(default_name) + .add_filter(filter_label, &[filter_ext]); + if let Some(dir) = seed_dir { + dlg = dlg.set_directory(dir); + } + dlg.save_file().await.map(|h| crate::sys::handle_path(&h)) + }, + Message::SaveDialogPathPicked, + ); + Task::batch([close, pick]) + } + // Web: no filesystem — serialize and hand the browser a download. + #[cfg(target_arch = "wasm32")] + { + let close = self.close_save_dialog_window(); + self.sync_view_state_for_save(i); + sync_annotation_scale_header(&mut self.tabs[i].scene); + self.stamp_header_sysvars(i); + self.sync_solid_models_for_save(i); + self.tabs[i].scene.sync_native_parametric_graph(); + let tab_id = self.tabs[i].id; + let bounds = + crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); + let Some(bounds) = bounds else { + return Task::batch([ + close, + Task::done(Message::WebSaveScreenshot { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds: None, + screenshot: None, + }), + ]); + }; + self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds, + }); + self.thumbnail_capture_clean = true; + close + } } /// Native: a destination path came back from the OS save dialog — write the @@ -2790,10 +2761,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); - std::env::temp_dir().join(format!( - "OpenCADStudio_{safe}_{}.sv$", - self.tabs[i].id - )) + std::env::temp_dir().join(format!("OpenCADStudio_{safe}_{}.sv$", self.tabs[i].id)) } } } @@ -2806,9 +2774,7 @@ pub(super) fn on_open_file(&mut self) -> Task { pub(super) fn on_autosave(&mut self) -> Task { let mut tasks = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty - || self.active_save_jobs.contains_key(&self.tabs[i].id) - { + if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) { continue; } self.prepare_native_save(i); @@ -2888,10 +2854,7 @@ pub(super) fn on_open_file(&mut self) -> Task { area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Layout, }; - ps.plot_view_name = plot_area - .strip_prefix("View: ") - .unwrap_or("") - .to_string(); + ps.plot_view_name = plot_area.strip_prefix("View: ").unwrap_or("").to_string(); if plot_area == "Window" { if let Some((x0, y0, x1, y1)) = plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -2961,10 +2924,13 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].dirty = true; self.tabs[i].scene.bump_geometry_no_blocks(); - self.command_line.push_info(crate::tf!( - "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ + self.command_line.push_info( + crate::tf!( + "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ center={center} rot={rotation}°" - ).as_ref()); + ) + .as_ref(), + ); } } @@ -2985,11 +2951,23 @@ pub(super) fn on_open_file(&mut self) -> Task { self.load_plotsettings_into_dialog(&settings); } } - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = self.direct_plot_params() else { - self.command_line - .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); + self.command_line.push_error( + crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(), + ); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); @@ -2997,22 +2975,22 @@ pub(super) fn on_open_file(&mut self) -> Task { let worker_path = path.clone(); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) - .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) - .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &worker_path, + plot_style.as_ref(), + render_options, + ) + .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) + .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -3028,8 +3006,19 @@ pub(super) fn on_open_file(&mut self) -> Task { "Extents" => self.extents_plot_job(), _ => self.window_plot_job(), }; - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - job + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); @@ -3041,35 +3030,36 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &worker_path, + plot_style.as_ref(), + render_options, + ) + .map(|_| { + crate::tf!( + "Plotted window to {}", + worker_path + .file_name() + .unwrap_or_default() + .to_string_lossy() ) - .map(|_| {crate::tf!( - "Plotted window to {}", - worker_path - .file_name().unwrap_or_default().to_string_lossy() - ).into_owned() - }).map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) + .into_owned() + }) + .map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) }; self.run_plot_work(self.plot_dialog.background, false, work) } - pub(super) fn report_plot_result( - &mut self, - result: Result, - reopen_plot: bool, - ) { + pub(super) fn report_plot_result(&mut self, result: Result, reopen_plot: bool) { match result { Ok(message) => self.command_line.push_info(&message), Err(error) => self.command_line.push_error(&error), @@ -3146,19 +3136,19 @@ pub(super) fn on_open_file(&mut self) -> Task { .document .header .paper_space_linetype_scaling; - let original_plimcheck = self.tabs[i] - .scene - .document - .header - .paper_space_limit_check; + let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check; let original_dialog = self.plot_dialog.clone(); let original_style = self.active_plot_style.clone(); let original_window = self.plot_window; let original_setup = self.plot_setup_template.clone(); let original_camera = self.tabs[i].scene.camera.borrow().clone(); let original_camera_generation = self.tabs[i].scene.camera_generation; - let override_dialog = self.print_all_settings_override.then(|| original_dialog.clone()); - let override_style = self.print_all_settings_override.then(|| original_style.clone()); + let override_dialog = self + .print_all_settings_override + .then(|| original_dialog.clone()); + let override_style = self + .print_all_settings_override + .then(|| original_style.clone()); let result = (|| { let mut pages = Vec::with_capacity(selected.len()); for name in selected { @@ -3189,7 +3179,8 @@ pub(super) fn on_open_file(&mut self) -> Task { return Err(crate::tf!( "Layout '{name}' plot style table '{}' is not loaded.", dialog.style_name - ).into_owned()); + ) + .into_owned()); } let plot_style = self.dialog_plot_style(&dialog); let params = match dialog.area.as_str() { @@ -3229,7 +3220,9 @@ pub(super) fn on_open_file(&mut self) -> Task { rotation_deg, scale, clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; + ) = params.ok_or_else(|| { + crate::tf!("Layout '{name}' plot area is empty.").into_owned() + })?; ( std::sync::Arc::new(wires), hatches, @@ -3268,11 +3261,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .document .header .paper_space_linetype_scaling = original_psltscale; - self.tabs[i] - .scene - .document - .header - .paper_space_limit_check = original_plimcheck; + self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck; self.plot_dialog = original_dialog; self.active_plot_style = original_style; self.plot_window = original_window; @@ -3282,19 +3271,12 @@ pub(super) fn on_open_file(&mut self) -> Task { result } - pub(super) fn on_print_all_pdf_path_some( - &mut self, - path: std::path::PathBuf, - ) -> Task { + pub(super) fn on_print_all_pdf_path_some(&mut self, path: std::path::PathBuf) -> Task { let dialog = self.plot_dialog.clone(); - if self.print_all_settings_override - && dialog.style_missing - && dialog.apply_plot_styles - { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - dialog.style_name - ).as_ref()); + if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles { + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(), + ); return Task::none(); } let pages = match self.print_all_pages() { @@ -3308,13 +3290,16 @@ pub(super) fn on_open_file(&mut self) -> Task { self.save_config(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf_pages( - &pages, - &worker_path, - None, - ) - .map(|_| crate::tf!("Exported {} layouts to {}", pages.len(), worker_path.display()).into_owned()) - .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) + crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, None) + .map(|_| { + crate::tf!( + "Exported {} layouts to {}", + pages.len(), + worker_path.display() + ) + .into_owned() + }) + .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) }; self.run_print_all_work(dialog.background, work) } @@ -3322,22 +3307,18 @@ pub(super) fn on_open_file(&mut self) -> Task { pub(super) fn on_print_all_print(&mut self) -> Task { #[cfg(target_arch = "wasm32")] { - self.command_line.push_error( - crate::t!("Printing is not available in the web version.").as_ref(), - ); + self.command_line + .push_error(crate::t!("Printing is not available in the web version.").as_ref()); Task::none() } #[cfg(not(target_arch = "wasm32"))] { let dialog = self.plot_dialog.clone(); - if self.print_all_settings_override - && dialog.style_missing - && dialog.apply_plot_styles + if self.print_all_settings_override && dialog.style_missing && dialog.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - dialog.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", dialog.style_name).as_ref(), + ); return Task::none(); } let pages = match self.print_all_pages() { @@ -3351,20 +3332,18 @@ pub(super) fn on_open_file(&mut self) -> Task { let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); - self.command_line.push_info( - crate::t!("Sending selected layouts to the system printer…").as_ref(), - ); + self.command_line + .push_info(crate::t!("Sending selected layouts to the system printer…").as_ref()); let work = move || { - crate::io::pdf_export::export_pdf_pages( - &pages, - &temp_path, - None, - ) - .and_then(|_| { - crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) - }) - .map(|printer| crate::tf!("Sent {} layouts to printer: {printer}", pages.len()).into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, None) + .and_then(|_| { + crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) + }) + .map(|printer| { + crate::tf!("Sent {} layouts to printer: {printer}", pages.len()) + .into_owned() + }) + .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; self.run_print_all_work(true, work) } @@ -3381,12 +3360,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - fn run_plot_work( - &mut self, - background: bool, - reopen_plot: bool, - work: F, - ) -> Task + fn run_plot_work(&mut self, background: bool, reopen_plot: bool, work: F) -> Task where F: FnOnce() -> Result + Send + 'static, { @@ -3414,12 +3388,11 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); - let (source_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. let wires = if paper_space { @@ -3456,21 +3429,12 @@ pub(super) fn on_open_file(&mut self) -> Task { // Only the explicit upside-down choice rotates layout content here. let rotation = dialog_rotation; let centered = self.plot_dialog.center; - let origin_x = self - .plot_dialog - .offset_x - .parse::() - .unwrap_or(0.0); - let origin_y = self - .plot_dialog - .offset_y - .parse::() - .unwrap_or(0.0); + let origin_x = self.plot_dialog.offset_x.parse::().unwrap_or(0.0); + let origin_y = self.plot_dialog.offset_y.parse::().unwrap_or(0.0); let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = - plot_content_extents(&wires, &hatches, &wipeouts) - .unwrap_or((x0, y0, x1, y1)); + plot_content_extents(&wires, &hatches, &wipeouts).unwrap_or((x0, y0, x1, y1)); let content_w = (max_x - min_x).max(1e-9); let content_h = (max_y - min_y).max(1e-9); let scale = if self.plot_dialog.fit_to_paper { @@ -3491,11 +3455,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { origin_y }; - ( - scale, - target_x / scale - min_x, - target_y / scale - min_y, - ) + (scale, target_x / scale - min_x, target_y / scale - min_y) } else if plot_layout { // Map the complete paper-space sheet to the physical paper // selected in the dialog. Layout bounds can be stored in mm, @@ -3510,11 +3470,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .max(1e-9); let target_x = (selected_sheet.0 - bounds_w * scale) * 0.5; let target_y = (selected_sheet.1 - bounds_h * scale) * 0.5; - ( - scale, - target_x / scale - x0, - target_y / scale - y0, - ) + (scale, target_x / scale - x0, target_y / scale - y0) } else { // Legacy direct callers still get dialog positioning. Normal // Display/Window paths use `area_plot_job` instead. @@ -3544,14 +3500,16 @@ pub(super) fn on_open_file(&mut self) -> Task { // ink is restricted to the device's printable rectangle. Extents // has its own fitted area and must not inherit this paper clip. let clip = if plot_layout { - scene.printable_area_limits().map(|((px0, py0), (px1, py1))| { - ( - (px0 + offset_x) as f32, - (py0 + offset_y) as f32, - (px1 - px0) as f32, - (py1 - py0) as f32, - ) - }) + scene + .printable_area_limits() + .map(|((px0, py0), (px1, py1))| { + ( + (px0 + offset_x) as f32, + (py0 + offset_y) as f32, + (px1 - px0) as f32, + (py1 - py0) as f32, + ) + }) } else { None }; @@ -3629,20 +3587,32 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_print_to_printer(&mut self) -> Task { - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(( + wires, + hatches, + wipeouts, + group_splits, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + )) = self.direct_plot_params() else { - self.command_line - .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); + self.command_line.push_error( + crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref(), + ); return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); let options = self.plot_print_options(&self.plot_dialog, group_splits); - self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); + self.command_line + .push_info(crate::t!("Sending to system printer…").as_ref()); background_task( move || { - iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( + iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, options, )) @@ -3882,7 +3852,9 @@ pub(super) fn on_open_file(&mut self) -> Task { match crate::io::print_to_printer::open_printer_properties( self.plot_dialog.printer.as_deref(), ) { - Ok(()) => self.command_line.push_info(crate::t!("Opened printer properties.").as_ref()), + Ok(()) => self + .command_line + .push_info(crate::t!("Opened printer properties.").as_ref()), Err(error) => self.command_line.push_error(&error), } Task::none() @@ -3918,11 +3890,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.offset_x = "0.0".into(); self.plot_dialog.offset_y = "0.0".into(); self.plot_dialog.fit_to_paper = false; - self.plot_dialog.scale = scale_name_for_factor( - &self.plot_dialog.scales, - 1.0, - ) - .unwrap_or_else(|| "1:1".into()); + self.plot_dialog.scale = scale_name_for_factor(&self.plot_dialog.scales, 1.0) + .unwrap_or_else(|| "1:1".into()); } self.plot_dialog.area = s; Task::none() @@ -3965,9 +3934,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } } PlotFlag::Center if d.area != "Layout" => d.center = !d.center, - PlotFlag::ScaleLw if !d.fit_to_paper => { - d.scale_lw = !d.scale_lw - } + PlotFlag::ScaleLw if !d.fit_to_paper => d.scale_lw = !d.scale_lw, PlotFlag::PlotStyles if !d.style_name.is_empty() => { d.apply_plot_styles = !d.apply_plot_styles } @@ -4043,9 +4010,10 @@ pub(super) fn on_open_file(&mut self) -> Task { } Task::none() } - M::SetCurrent => { + M::SetCurrent => { self.apply_dialog_to_layout(); - self.command_line.push_info(crate::t!("Page setup applied to the layout.").as_ref()); + self.command_line + .push_info(crate::t!("Page setup applied to the layout.").as_ref()); Task::none() } M::NewSetup => { @@ -4128,7 +4096,9 @@ pub(super) fn on_open_file(&mut self) -> Task { if !name.is_empty() { if self.plot_dialog.name_rename { let old = self.plot_dialog.selected_setup.clone(); - self.tabs[self.active_tab].scene.page_setup_rename(&old, &name); + self.tabs[self.active_tab] + .scene + .page_setup_rename(&old, &name); } else { let ps = self.dialog_to_plotsettings(); self.tabs[self.active_tab].scene.page_setup_save(&name, ps); @@ -4149,10 +4119,13 @@ pub(super) fn on_open_file(&mut self) -> Task { M::Preview => self.on_plot_dlg_commit(true), M::Commit if self.print_all_options => { if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - self.plot_dialog.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Plot style table '{}' is not loaded.", + self.plot_dialog.style_name + ) + .as_ref(), + ); return Task::none(); } self.plot_dialog.paper_space = true; @@ -4201,7 +4174,11 @@ pub(super) fn on_open_file(&mut self) -> Task { d.orientation = "Landscape".into(); d.paper_width_mm = 297.0; d.paper_height_mm = 210.0; - d.area = if is_model { "Window".into() } else { "Layout".into() }; + d.area = if is_model { + "Window".into() + } else { + "Layout".into() + }; d.center = true; d.offset_x = "0.0".into(); d.offset_y = "0.0".into(); @@ -4216,8 +4193,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.copy_settings_from(&prev); } } else if is_layout_entry(name) { - if let Some(ps) = self - .tabs[self.active_tab] + if let Some(ps) = self.tabs[self.active_tab] .scene .plot_settings_for(layout_entry_name(name)) { @@ -4269,11 +4245,7 @@ pub(super) fn on_open_file(&mut self) -> Task { area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Extents, }; - ps.plot_view_name = d - .area - .strip_prefix("View: ") - .unwrap_or("") - .to_string(); + ps.plot_view_name = d.area.strip_prefix("View: ").unwrap_or("").to_string(); if d.area == "Window" { if let Some((x0, y0, x1, y1)) = self.plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -4339,9 +4311,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { - use acadrust::objects::{ - PlotType, ShadePlotMode, ShadePlotResolutionLevel, - }; + use acadrust::objects::{PlotType, ShadePlotMode, ShadePlotResolutionLevel}; self.plot_setup_template = Some(ps.clone()); if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { self.plot_window = Some(( @@ -4353,17 +4323,20 @@ pub(super) fn on_open_file(&mut self) -> Task { } if !ps.current_style_sheet.is_empty() && self - .active_plot_style - .as_ref() - .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) + .active_plot_style + .as_ref() + .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) { - if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named( - &ps.current_style_sheet, - ) { + if let Ok(table) = + crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) + { self.active_plot_style = Some(table); } } - let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); + let active_style_name = self + .active_plot_style + .as_ref() + .map(|table| table.name.clone()); let style_name = if ps.current_style_sheet.is_empty() { String::new() } else if active_style_name @@ -4382,8 +4355,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if !matches!( paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" - ) - && !ps.paper_size.is_empty() + ) && !ps.paper_size.is_empty() { paper = ps.paper_size.clone(); } @@ -4507,25 +4479,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); - self.apply_plot_page_settings( - sheet_w, - sheet_h, - &d.area, - d.center, - off_x, - off_y, - rotation, - ); + self.apply_plot_page_settings(sheet_w, sheet_h, &d.area, d.center, off_x, off_y, rotation); } /// Open a preview PDF, export a PDF, or send the job to the chosen printer. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { let d = self.plot_dialog.clone(); if d.style_missing && d.apply_plot_styles { - self.command_line.push_error(crate::tf!( - "Plot style table '{}' is not loaded.", - d.style_name - ).as_ref()); + self.command_line.push_error( + crate::tf!("Plot style table '{}' is not loaded.", d.style_name).as_ref(), + ); return Task::none(); } // Remember the user's print preferences across sessions. @@ -4576,11 +4539,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + &w_wires, + &w_hatches, + &w_wipeouts, + sw, + sh, + wox, + woy, + wrotation, + wscale, + wclip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + .map(|_| crate::t!("Opened plot preview.").into_owned()) + .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } @@ -4593,12 +4568,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let opts = self.plot_print_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) + &w_wires, + &w_hatches, + &w_wipeouts, + sw, + sh, + wox, + woy, + wrotation, + wscale, + wclip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) + .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) + .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) }; return self.run_plot_work(true, false, work); } @@ -4611,21 +4597,23 @@ pub(super) fn on_open_file(&mut self) -> Task { let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) - .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + &wires, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, + &tmp, + plot_style.as_ref(), + render_options, + ) + .and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + .map(|_| crate::t!("Opened plot preview.").into_owned()) + .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); } @@ -4636,15 +4624,15 @@ pub(super) fn on_open_file(&mut self) -> Task { } let opts = self.plot_print_options(&d, group_splits); - self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); + self.command_line + .push_info(crate::t!("Sending to system printer…").as_ref()); let work = move || { - iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, opts, - )) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with( + wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, + plot_style, opts, + )) + .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) + .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; self.run_plot_work(true, false, work) } @@ -4700,9 +4688,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn limits_plot_job(&self) -> Option { - let (min, max) = self.tabs[self.active_tab] - .scene - .current_drawing_limits()?; + let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } @@ -4714,7 +4700,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .iter() .find(|view| { view.name.eq_ignore_ascii_case(name) - && view.paper_space == (self.tabs[self.active_tab].scene.current_layout != "Model") + && view.paper_space + == (self.tabs[self.active_tab].scene.current_layout != "Model") })?; let half_w = view.width.abs() * 0.5; let half_h = view.height.abs() * 0.5; @@ -4731,19 +4718,13 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { let (min, max) = scene.model_space_extents()?; - return self.area_plot_job(( - min.x as f64, - min.y as f64, - max.x as f64, - max.y as f64, - )); + return self.area_plot_job((min.x as f64, min.y as f64, max.x as f64, max.y as f64)); } - let (wires, hatches, wipeouts, _) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (wires, hatches, wipeouts, _) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let extents = plot_content_extents( &wires .iter() @@ -4792,12 +4773,7 @@ pub(super) fn on_open_file(&mut self) -> Task { iced::Point::new(bounds.width, bounds.height), iced::Point::new(0.0, bounds.height), ] { - let world = camera.unproject_on_plane( - point, - bounds, - glam::Vec3::Z, - glam::DVec3::ZERO, - ); + let world = camera.unproject_on_plane(point, bounds, glam::Vec3::Z, glam::DVec3::ZERO); x0 = x0.min(world.x); y0 = y0.min(world.y); x1 = x1.max(world.x); @@ -4853,21 +4829,24 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let (all_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); + let (all_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] .iter() .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .filter(|w| { + w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 + }) .count(); let wires: Vec<_> = all_wires .iter() .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .filter(|w| { + w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 + }) .cloned() .collect(); group_splits.wires = first_wire_count; @@ -4900,79 +4879,81 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_plot_style_panel_apply(&mut self) -> Task { - let aci = self.plotstyle_panel_aci as usize; - if let Some(table) = self.active_plot_style.as_mut() { - if let Some(entry) = table.aci_entries.get_mut(aci) { - // Parse color. - let color_str = self.ps_color_buf.trim(); - if color_str.is_empty() { - entry.color = None; - } else if color_str.starts_with('#') && color_str.len() == 7 { - let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); - let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); - let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); - entry.color = Some([r, g, b]); - } - if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { - entry.lineweight = lw; - } - if let Ok(sc) = self.ps_screening_buf.trim().parse::() { - entry.screening = sc.min(100); - } - - } - } else { - // No table loaded: create an identity table and apply. - let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb"); - if let Some(entry) = table.aci_entries.get_mut(aci) { - let color_str = self.ps_color_buf.trim(); - if color_str.starts_with('#') && color_str.len() == 7 { - let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); - let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); - let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); - entry.color = Some([r, g, b]); - } - if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { - entry.lineweight = lw; - } - if let Ok(sc) = self.ps_screening_buf.trim().parse::() { - entry.screening = sc.min(100); - } - } - self.active_plot_style = Some(table); - self.command_line - .push_output(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref()); + let aci = self.plotstyle_panel_aci as usize; + if let Some(table) = self.active_plot_style.as_mut() { + if let Some(entry) = table.aci_entries.get_mut(aci) { + // Parse color. + let color_str = self.ps_color_buf.trim(); + if color_str.is_empty() { + entry.color = None; + } else if color_str.starts_with('#') && color_str.len() == 7 { + let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); + let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); + let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); + entry.color = Some([r, g, b]); } - Task::none() + if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { + entry.lineweight = lw; + } + if let Ok(sc) = self.ps_screening_buf.trim().parse::() { + entry.screening = sc.min(100); + } + } + } else { + // No table loaded: create an identity table and apply. + let mut table = crate::io::plot_style::PlotStyleTable::identity("Custom.ctb"); + if let Some(entry) = table.aci_entries.get_mut(aci) { + let color_str = self.ps_color_buf.trim(); + if color_str.starts_with('#') && color_str.len() == 7 { + let r = u8::from_str_radix(&color_str[1..3], 16).unwrap_or(0); + let g = u8::from_str_radix(&color_str[3..5], 16).unwrap_or(0); + let b = u8::from_str_radix(&color_str[5..7], 16).unwrap_or(0); + entry.color = Some([r, g, b]); + } + if let Ok(lw) = self.ps_lineweight_buf.trim().parse::() { + entry.lineweight = lw; + } + if let Ok(sc) = self.ps_screening_buf.trim().parse::() { + entry.screening = sc.min(100); + } + } + self.active_plot_style = Some(table); + self.command_line + .push_output(crate::tf!("Created new CTB table, ACI {aci} updated.").as_ref()); + } + Task::none() } pub(super) fn on_plot_style_panel_save(&mut self) -> Task { - if self.active_plot_style.is_none() { - self.command_line - .push_error(crate::t!("No plot style table loaded. Load or create one first.").as_ref()); - return Task::none(); - } - let default_name = self - .active_plot_style - .as_ref() - .map(|t| t.name.clone()) - .unwrap_or("export.ctb".into()); - Task::perform( - async move { - let dialog = crate::sys::file_dialog() - .set_title(crate::t!("Save Plot Style Table").as_ref()) - .set_file_name(&default_name) - .add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]); - #[cfg(not(target_arch = "wasm32"))] - let dialog = match crate::io::plot_style::ensure_plot_styles_dir() { - Ok(dir) => dialog.set_directory(dir), - Err(_) => dialog, - }; - dialog.save_file().await - .map(|h| crate::sys::handle_path(&h)) - }, - Message::PlotStylePanelSavePath, - ) + if self.active_plot_style.is_none() { + self.command_line.push_error( + crate::t!("No plot style table loaded. Load or create one first.").as_ref(), + ); + return Task::none(); + } + let default_name = self + .active_plot_style + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or("export.ctb".into()); + Task::perform( + async move { + let dialog = crate::sys::file_dialog() + .set_title(crate::t!("Save Plot Style Table").as_ref()) + .set_file_name(&default_name) + .add_filter(crate::t!("Plot Style Files").as_ref(), &["ctb", "CTB"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]); + #[cfg(not(target_arch = "wasm32"))] + let dialog = match crate::io::plot_style::ensure_plot_styles_dir() { + Ok(dir) => dialog.set_directory(dir), + Err(_) => dialog, + }; + dialog + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + Message::PlotStylePanelSavePath, + ) } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9cfbe5769..4d3919596 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -130,9 +130,7 @@ impl OpenCADStudio { // Closing it restores the parent Plot dialog instead of returning // to the drawing. if self.active_modal == Some(Plotstyle) { - if let Some((plot_offset, plot_resize)) = - self.plotstyle_parent_plot_geometry.take() - { + if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.take() { self.active_modal = Some(Plot); self.reset_modal_geometry(); @@ -347,10 +345,7 @@ impl OpenCADStudio { if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 5.0 { - crate::perf_record!( - "[perf] update {:>7.1}ms message={perf_label}", - elapsed_ms, - ); + crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,); } } #[cfg(target_arch = "wasm32")] @@ -379,14 +374,29 @@ impl OpenCADStudio { envelope.reply.send(response); task } - #[cfg(target_arch="wasm32")] - Message::PollWebControl => if let Some(request)=super::control::web_request(){self.update(Message::ControlRequest(request))}else{Task::none()}, - #[cfg(not(target_arch="wasm32"))] + #[cfg(target_arch = "wasm32")] + Message::PollWebControl => { + if let Some(request) = super::control::web_request() { + self.update(Message::ControlRequest(request)) + } else { + Task::none() + } + } + #[cfg(not(target_arch = "wasm32"))] Message::PollWebControl => Task::none(), Message::ControlStep(id, message) => self.control_step(id, *message), - Message::ControlTaskDone(id) => { self.control_task_done(&id); Task::none() } - Message::ControlScreenshot(path, screenshot) => { self.control_screenshot(path, screenshot); Task::none() } - Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() } + Message::ControlTaskDone(id) => { + self.control_task_done(&id); + Task::none() + } + Message::ControlScreenshot(path, screenshot) => { + self.control_screenshot(path, screenshot); + Task::none() + } + Message::ControlToggle => { + self.control.enabled = !self.control.enabled; + Task::none() + } // Drain plugin-to-host requests that arrived outside of a host call. // This runs on a periodic timer so long-lived plugin sessions such // as the Python REPL can mutate the document without requiring a @@ -407,7 +417,8 @@ impl OpenCADStudio { { continue; } - let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text); + let prefixed = + format!("[{} {}] {}", line.plugin_id, line.source, line.text); match line.source { ocs_plugin_api::process::IoStream::Stderr => { self.command_line.push_error(&prefixed); @@ -461,18 +472,14 @@ impl OpenCADStudio { return Task::none(); }; iced::font::load((*bytes).clone()).map(move |result| { - Message::WebUiFontLoaded( - script, - result.map_err(|error| format!("{error:?}")), - ) + Message::WebUiFontLoaded(script, result.map_err(|error| format!("{error:?}"))) }) } Message::WebUiFontLoaded(script, result) => { if let Err(error) = result { - self.command_line.push_error( - crate::tf!("Font load failed ({script:?}): {error}").as_ref(), - ); + self.command_line + .push_error(crate::tf!("Font load failed ({script:?}): {error}").as_ref()); return Task::none(); } if script == crate::scene::text::web_font::primary_script() { @@ -510,10 +517,10 @@ impl OpenCADStudio { return match std::fs::metadata(&path) { Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))), Err(_) => { - self.command_line.push_error(crate::tf!( - "Recent file no longer exists: {}", - path.display() - ).as_ref()); + self.command_line.push_error( + crate::tf!("Recent file no longer exists: {}", path.display()) + .as_ref(), + ); Task::none() } }; @@ -543,10 +550,9 @@ impl OpenCADStudio { recovery_read_stats: None, recovery_bytes: None, }); - Task::perform( - crate::io::open_recent_web(path, state), - move |outcome| Message::WebFileOpened(open_id, outcome), - ) + Task::perform(crate::io::open_recent_web(path, state), move |outcome| { + Message::WebFileOpened(open_id, outcome) + }) } } @@ -575,9 +581,7 @@ impl OpenCADStudio { ]), None => Task::none(), }; - if self.opening.is_some() - || self.active_modal == Some(super::ModalKind::Recovery) - { + if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); raise } else if let Some(idx) = self.tab_showing(&path) { @@ -617,9 +621,7 @@ impl OpenCADStudio { // on the same widget, so remembering the last text seen pairs // it with the focus check. (An empty field reports its // placeholder — that's iced's "visible text" contract.) - use iced::advanced::widget::operation::{ - Focusable, Outcome, TextInput, - }; + use iced::advanced::widget::operation::{Focusable, Outcome, TextInput}; use iced::advanced::widget::{Id, Operation}; #[derive(Default)] struct FocusedText { @@ -700,9 +702,8 @@ impl OpenCADStudio { if allowed { self.start_mtp_modifier(i); } else { - self.command_line.push_info( - crate::t!("MTP needs an active point prompt.").as_ref(), - ); + self.command_line + .push_info(crate::t!("MTP needs an active point prompt.").as_ref()); } Task::none() } @@ -721,17 +722,14 @@ impl OpenCADStudio { .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); if !matches!(ext.as_str(), "dwg" | "dxf" | "bak" | "sv$") { - self.command_line.push_error(crate::tf!( - "Unsupported file type: {}", - path.display() - ).as_ref()); + self.command_line.push_error( + crate::tf!("Unsupported file type: {}", path.display()).as_ref(), + ); return Task::none(); } // A load or recovery report owns the open slot; queue another // drop until that state is acknowledged. - if self.opening.is_some() - || self.active_modal == Some(super::ModalKind::Recovery) - { + if self.opening.is_some() || self.active_modal == Some(super::ModalKind::Recovery) { self.pending_opens.push_back(path); Task::none() } else if let Some(idx) = self.tab_showing(&path) { @@ -780,15 +778,14 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] recovery_bytes: None, #[cfg(not(target_arch = "wasm32"))] - fingerprint: - crate::io::edit_lock::FileFingerprint::capture(&path).ok(), + fingerprint: crate::io::edit_lock::FileFingerprint::capture(&path).ok(), }); let size_label = format_size(size_bytes); self.command_line .push_info(crate::tf!("Opening \"{name}\" ({size_label})…").as_ref()); - let model_bg = self.default_bg_color.unwrap_or_else(|| { - self.model_space.resolve_model_bg(&self.active_theme) - }); + let model_bg = self + .default_bg_color + .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); Task::perform( crate::io::open_path_with_phase(path, progress, model_bg), move |result| Message::FileOpened(open_id, result), @@ -824,9 +821,7 @@ impl OpenCADStudio { crate::io::web_recent::store_open(&name, bytes, open_id).await; (outcome, result) }, - move |(outcome, result)| { - Message::WebFileCached(open_id, outcome, result) - }, + move |(outcome, result)| Message::WebFileCached(open_id, outcome, result), ); } let recent_task = if outcome.record_recent && outcome.result.is_ok() { @@ -846,9 +841,12 @@ impl OpenCADStudio { let recent_task = match cache_result { Ok(()) => self.push_recent(std::path::PathBuf::from(&outcome.name)), Err(error) => { - self.command_line.push_error(crate::tf!( - "Opened drawing, but recent copy could not be stored: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "Opened drawing, but recent copy could not be stored: {error}" + ) + .as_ref(), + ); Task::none() } }; @@ -879,7 +877,8 @@ impl OpenCADStudio { // we suppress the noise. let opening = self.opening.take(); if let Some(opening) = opening.filter(|_| e.message != "Cancelled") { - self.command_line.push_error(crate::tf!("Open failed: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("Open failed: {e}").as_ref()); let total_ms = opening.started.elapsed().as_millis() as u32; let failure_phase = crate::io::open_phase_name( opening @@ -912,9 +911,10 @@ impl OpenCADStudio { Message::WebRecentStored(result) => match result { Ok(path) => self.push_recent(path), Err(error) => { - self.command_line.push_error(crate::tf!( - "Saved download, but recent copy could not be stored: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!("Saved download, but recent copy could not be stored: {error}") + .as_ref(), + ); Task::none() } }, @@ -943,7 +943,8 @@ impl OpenCADStudio { Message::ImagePickResult(Err(e)) => { if e != "Cancelled" { - self.command_line.push_error(crate::tf!("IMAGE: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("IMAGE: {e}").as_ref()); } Task::none() } @@ -968,9 +969,9 @@ impl OpenCADStudio { ), Message::PdfAttachPickResult(Ok((path, bytes))) => { - use acadrust::objects::{ObjectType, UnderlayDefinition}; use crate::command::CadCommand; use crate::modules::insert::pdf_attach::PdfAttachCommand; + use acadrust::objects::{ObjectType, UnderlayDefinition}; let i = self.active_tab; let path_str = path.to_string_lossy().into_owned(); @@ -981,14 +982,10 @@ impl OpenCADStudio { let mut definition = UnderlayDefinition::pdf(&path_str, "1"); definition.handle = definition_handle; - self.tabs[i] - .scene - .document - .objects - .insert( - definition_handle, - ObjectType::UnderlayDefinition(definition), - ); + self.tabs[i].scene.document.objects.insert( + definition_handle, + ObjectType::UnderlayDefinition(definition), + ); let cmd = PdfAttachCommand::new(definition_handle); @@ -1010,7 +1007,10 @@ impl OpenCADStudio { async { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select External Reference File").as_ref()) - .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"]) + .add_filter( + crate::t!("CAD Files").as_ref(), + &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"], + ) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"]) .add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"]) @@ -1037,7 +1037,8 @@ impl OpenCADStudio { Message::XAttachPickResult(Err(e)) => { if e != "Cancelled" { - self.command_line.push_error(crate::tf!("XATTACH: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("XATTACH: {e}").as_ref()); } Task::none() } @@ -1067,10 +1068,10 @@ impl OpenCADStudio { Message::WblockWriteFinished(block_name, path, result) => { match result { - Ok(()) => self.command_line.push_output(crate::tf!( - "WBLOCK Saved \"{block_name}\" → \"{}\"", - path.display() - ).as_ref()), + Ok(()) => self.command_line.push_output( + crate::tf!("WBLOCK Saved \"{block_name}\" → \"{}\"", path.display()) + .as_ref(), + ), Err(error) => self .command_line .push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()), @@ -1157,8 +1158,9 @@ impl OpenCADStudio { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string_lossy().into_owned()); - self.command_line - .push_output(crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref()); + self.command_line.push_output( + crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(), + ); } Err(e) => self .command_line @@ -1197,10 +1199,12 @@ impl OpenCADStudio { Message::StlExportFinished(path, result) => { match result { - Ok(()) => self + Ok(()) => self.command_line.push_output( + crate::tf!("STLOUT: exported to \"{}\"", path.display()).as_ref(), + ), + Err(error) => self .command_line - .push_output(crate::tf!("STLOUT: exported to \"{}\"", path.display()).as_ref()), - Err(error) => self.command_line.push_error(crate::tf!("STLOUT: {error}").as_ref()), + .push_error(crate::tf!("STLOUT: {error}").as_ref()), } Task::none() } @@ -1209,8 +1213,9 @@ impl OpenCADStudio { Message::StepExport => { let i = self.active_tab; if self.tabs[i].scene.meshes.is_empty() { - self.command_line - .push_error(crate::t!("STEPOUT: no 3D mesh data in this drawing.").as_ref()); + self.command_line.push_error( + crate::t!("STEPOUT: no 3D mesh data in this drawing.").as_ref(), + ); return Task::none(); } Task::perform( @@ -1234,10 +1239,12 @@ impl OpenCADStudio { Message::StepExportFinished(path, result) => { match result { - Ok(()) => self + Ok(()) => self.command_line.push_output( + crate::tf!("STEPOUT: exported to \"{}\"", path.display()).as_ref(), + ), + Err(error) => self .command_line - .push_output(crate::tf!("STEPOUT: exported to \"{}\"", path.display()).as_ref()), - Err(error) => self.command_line.push_error(crate::tf!("STEPOUT: {error}").as_ref()), + .push_error(crate::tf!("STEPOUT: {error}").as_ref()), } Task::none() } @@ -1262,11 +1269,14 @@ impl OpenCADStudio { Message::ObjImportFinished(tab_id, path, result) => { match result { - Err(error) => self.command_line.push_error(crate::tf!("IMPORTOBJ: {error}").as_ref()), + Err(error) => self + .command_line + .push_error(crate::tf!("IMPORTOBJ: {error}").as_ref()), Ok(mut mesh) => { let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { - self.command_line - .push_info(crate::t!("IMPORTOBJ: target drawing was closed.").as_ref()); + self.command_line.push_info( + crate::t!("IMPORTOBJ: target drawing was closed.").as_ref(), + ); return Task::none(); }; let file_stem = path @@ -1283,9 +1293,9 @@ impl OpenCADStudio { .meshes .insert(handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "IMPORTOBJ: imported \"{file_stem}\" as mesh." - ).as_ref()); + self.command_line.push_output( + crate::tf!("IMPORTOBJ: imported \"{file_stem}\" as mesh.").as_ref(), + ); } } } @@ -1296,8 +1306,9 @@ impl OpenCADStudio { Message::SaveAs => { if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); return Task::none(); } let i = self.active_tab; @@ -1383,15 +1394,16 @@ impl OpenCADStudio { Projection::Perspective }; let i = self.active_tab; - self.tabs[i] - .scene - .set_projection_preserving_frame(proj); + self.tabs[i].scene.set_projection_preserving_frame(proj); self.ribbon.set_ortho(ortho); - self.command_line.push_output(crate::t!(if ortho { - "Projection: Orthographic" - } else { - "Projection: Perspective" - }).as_ref()); + self.command_line.push_output( + crate::t!(if ortho { + "Projection: Orthographic" + } else { + "Projection: Perspective" + }) + .as_ref(), + ); Task::none() } @@ -1512,9 +1524,7 @@ impl OpenCADStudio { &self.tabs[idx].scene.document.header.code_page, ) { crate::scene::text::ttf_glyph::clear_fallback_cache(); - self.tabs[idx] - .scene - .invalidate_text_geometry_dependencies(); + self.tabs[idx].scene.invalidate_text_geometry_dependencies(); } } Task::none() @@ -1526,8 +1536,7 @@ impl OpenCADStudio { } Message::TabReorder { from, to, after } => { - let Some(insertion) = - reorder_insertion_index(from, to, after, self.tabs.len()) + let Some(insertion) = reorder_insertion_index(from, to, after, self.tabs.len()) else { return Task::none(); }; @@ -1584,8 +1593,9 @@ impl OpenCADStudio { { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { - self.command_line - .push_error(crate::t!("Save the drawing before copying its file path.").as_ref()); + self.command_line.push_error( + crate::t!("Save the drawing before copying its file path.").as_ref(), + ); return Task::none(); }; let full_path = path.canonicalize().unwrap_or_else(|_| { @@ -1605,8 +1615,10 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] { let _ = idx; - self.command_line - .push_error(crate::t!("Full file paths are unavailable in the web application.").as_ref()); + self.command_line.push_error( + crate::t!("Full file paths are unavailable in the web application.") + .as_ref(), + ); Task::none() } } @@ -1616,25 +1628,29 @@ impl OpenCADStudio { { let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) else { - self.command_line - .push_error(crate::t!("Save the drawing before opening its file location.").as_ref()); + self.command_line.push_error( + crate::t!("Save the drawing before opening its file location.") + .as_ref(), + ); return Task::none(); }; match crate::sys::reveal_in_file_manager(&path) { - Ok(()) => self - .command_line - .push_output(crate::tf!("Opened file location: {}", path.display()).as_ref()), - Err(error) => self - .command_line - .push_error(crate::tf!("Could not open file location: {error}").as_ref()), + Ok(()) => self.command_line.push_output( + crate::tf!("Opened file location: {}", path.display()).as_ref(), + ), + Err(error) => self.command_line.push_error( + crate::tf!("Could not open file location: {error}").as_ref(), + ), } Task::none() } #[cfg(target_arch = "wasm32")] { let _ = idx; - self.command_line - .push_error(crate::t!("File locations are unavailable in the web application.").as_ref()); + self.command_line.push_error( + crate::t!("File locations are unavailable in the web application.") + .as_ref(), + ); Task::none() } } @@ -1651,7 +1667,11 @@ impl OpenCADStudio { // MTEXT bodies), where the typed case is the content and // Space must stay in the buffer. let text_with_spaces = self.is_free_text_active(); - let s = if text_with_spaces { s } else { s.to_uppercase() }; + let s = if text_with_spaces { + s + } else { + s.to_uppercase() + }; // A space submits (acts like Enter). The whole value is handed to // the submit path, which tokenises multi-token lines — so a typed // token, a pasted `LINE 0,0 10,10`, or API-fed text all run their @@ -1692,15 +1712,16 @@ impl OpenCADStudio { let i = self.active_tab; // Capture prompt/options while holding cmd borrow, then release before // borrowing command_line to satisfy borrow checker. - let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live_input) { - (true, cmd.options(), cmd.prompt()) + let (should_update, opts, prompt) = + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live_input) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } } else { (false, Vec::new(), String::new()) - } - } else { - (false, Vec::new(), String::new()) - }; + }; if should_update { self.command_line.set_step_options(opts); // Update pinned prompt text live without pushing new history entry @@ -1816,16 +1837,14 @@ impl OpenCADStudio { Message::CommandLineArrowProbe { direction, extend_selection, - } => { - iced::widget::operation::is_focused(iced::widget::Id::new( - crate::ui::command_line::CMD_INPUT_ID, - )) - .map(move |focused| Message::CommandLineArrowResolved { - direction, - focused, - extend_selection, - }) - } + } => iced::widget::operation::is_focused(iced::widget::Id::new( + crate::ui::command_line::CMD_INPUT_ID, + )) + .map(move |focused| Message::CommandLineArrowResolved { + direction, + focused, + extend_selection, + }), Message::CommandLineArrowResolved { direction, @@ -1860,12 +1879,8 @@ impl OpenCADStudio { match direction { ArrowKey::Left => self.mtext_caret_move(-1, extend_selection), ArrowKey::Right => self.mtext_caret_move(1, extend_selection), - ArrowKey::Up => { - self.mtext_caret_move_vertical(1, extend_selection) - } - ArrowKey::Down => { - self.mtext_caret_move_vertical(-1, extend_selection) - } + ArrowKey::Up => self.mtext_caret_move_vertical(1, extend_selection), + ArrowKey::Down => self.mtext_caret_move_vertical(-1, extend_selection), } Task::none() } else { @@ -1908,10 +1923,7 @@ impl OpenCADStudio { let max_height = crate::ui::command_line::history_max_height(self.win_size.1); self.command_line.history_height = (self.command_line.history_height - dy) - .clamp( - crate::ui::command_line::HISTORY_HEIGHT_MIN, - max_height, - ); + .clamp(crate::ui::command_line::HISTORY_HEIGHT_MIN, max_height); } self.command_history_drag_last = Some(point); } @@ -1929,8 +1941,7 @@ impl OpenCADStudio { } Message::CommandHistoryHeightReset => { - self.command_line.history_height = - crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; + self.command_line.history_height = crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; self.save_config(); Task::none() } @@ -1947,10 +1958,16 @@ impl OpenCADStudio { crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref(), crate::t!("Close").as_ref(), ); - Task::perform(async move { - wasm_bindgen_futures::JsFuture::from(promise).await - .ok().and_then(|value| value.as_bool()).unwrap_or(false) - }, Message::CommandHistoryCopied) + Task::perform( + async move { + wasm_bindgen_futures::JsFuture::from(promise) + .await + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }, + Message::CommandHistoryCopied, + ) } #[cfg(not(target_arch = "wasm32"))] iced::clipboard::write(text).discard() @@ -2114,8 +2131,9 @@ impl OpenCADStudio { let i = self.active_tab; self.ribbon.close_dropdown(); if self.tabs[i].is_start { - self.command_line - .push_info(crate::t!("Open or create a drawing to manage layer states.").as_ref()); + self.command_line.push_info( + crate::t!("Open or create a drawing to manage layer states.").as_ref(), + ); return Task::none(); } let mut names: Vec = self.tabs[i] @@ -2131,13 +2149,12 @@ impl OpenCADStudio { Task::none() } // ── Layer Translator (#624) ────────────────────────────────── - Message::LayerTranslatorLoad => Task::perform( - crate::io::pick_layer_standard_path(), - |path| match path { + Message::LayerTranslatorLoad => { + Task::perform(crate::io::pick_layer_standard_path(), |path| match path { Some(path) => Message::LayerTranslatorLoaded(path), None => Message::Noop, - }, - ), + }) + } Message::LayerTranslatorLoaded(path) => { use crate::modules::draw::layers::laytrans; match laytrans::load_targets(&path) { @@ -2181,7 +2198,9 @@ impl OpenCADStudio { if let (Some(from), Some(to)) = (state.selected_from.take(), state.selected_to.clone()) { - state.mappings.retain(|m| !m.from.eq_ignore_ascii_case(&from)); + state + .mappings + .retain(|m| !m.from.eq_ignore_ascii_case(&from)); state.mappings.push(Mapping { from, to }); } } @@ -2295,17 +2314,18 @@ impl OpenCADStudio { return Task::none(); } let old_name = self.layer_state_selected.clone(); - let duplicate = self.tabs[i] - .scene - .document - .layer_states() - .into_iter() - .any(|state| { - state.name.eq_ignore_ascii_case(&name) - && old_name - .as_deref() - .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) - }); + let duplicate = + self.tabs[i] + .scene + .document + .layer_states() + .into_iter() + .any(|state| { + state.name.eq_ignore_ascii_case(&name) + && old_name + .as_deref() + .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) + }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); @@ -2321,15 +2341,16 @@ impl OpenCADStudio { .rename_layer_state(old_name, &name); } } - self.tabs[i].scene.document.capture_layer_state( - &name, - self.layer_state_description_buf.trim(), - ); + self.tabs[i] + .scene + .document + .capture_layer_state(&name, self.layer_state_description_buf.trim()); self.tabs[i].dirty = true; self.layer_state_selected = Some(name.clone()); self.layer_state_name_buf = name.clone(); - self.command_line - .push_output(crate::tf!("LAYERSTATE: saved \"{name}\" in the drawing.").as_ref()); + self.command_line.push_output( + crate::tf!("LAYERSTATE: saved \"{name}\" in the drawing.").as_ref(), + ); Task::none() } Message::LayerStateManagerRestore => { @@ -2362,9 +2383,9 @@ impl OpenCADStudio { .invalidate_layer_dependencies(&layer_names); self.tabs[i].dirty = true; self.refresh_layer_panel(); - self.command_line.push_output(crate::tf!( - "LAYERSTATE: restored \"{name}\" ({restored} layer(s))." - ).as_ref()); + self.command_line.push_output( + crate::tf!("LAYERSTATE: restored \"{name}\" ({restored} layer(s)).").as_ref(), + ); Task::none() } Message::LayerStateManagerDelete => { @@ -2416,9 +2437,7 @@ impl OpenCADStudio { } super::LayerStateProperty::Color => acadrust::LayerStateMask::COLOR, super::LayerStateProperty::LineType => acadrust::LayerStateMask::LINE_TYPE, - super::LayerStateProperty::LineWeight => { - acadrust::LayerStateMask::LINE_WEIGHT - } + super::LayerStateProperty::LineWeight => acadrust::LayerStateMask::LINE_WEIGHT, super::LayerStateProperty::PlotStyle => acadrust::LayerStateMask::PLOT_STYLE, super::LayerStateProperty::Transparency => { acadrust::LayerStateMask::TRANSPARENCY @@ -2450,12 +2469,12 @@ impl OpenCADStudio { Task::none() } Message::LayerStateEditorLayerColorToggle(index) => { - self.layer_state_edit_color_open = if self.layer_state_edit_color_open == Some(index) - { - None - } else { - Some(index) - }; + self.layer_state_edit_color_open = + if self.layer_state_edit_color_open == Some(index) { + None + } else { + Some(index) + }; Task::none() } Message::LayerStateEditorLayerColor(index, color) => { @@ -2543,17 +2562,18 @@ impl OpenCADStudio { return Task::none(); } let old_name = self.layer_state_selected.clone(); - let duplicate = self.tabs[i] - .scene - .document - .layer_states() - .into_iter() - .any(|state| { - state.name.eq_ignore_ascii_case(&name) - && old_name - .as_deref() - .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) - }); + let duplicate = + self.tabs[i] + .scene + .document + .layer_states() + .into_iter() + .any(|state| { + state.name.eq_ignore_ascii_case(&name) + && old_name + .as_deref() + .is_none_or(|old| !state.name.eq_ignore_ascii_case(old)) + }); if duplicate { self.command_line .push_error(crate::tf!("Layer state \"{name}\" already exists.").as_ref()); @@ -2629,8 +2649,11 @@ impl OpenCADStudio { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.off = !on; } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.visible = on; } @@ -2638,11 +2661,14 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) turned {}", - targets.len(), - if on { "on" } else { "off" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) turned {}", + targets.len(), + if on { "on" } else { "off" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); } } @@ -2669,8 +2695,11 @@ impl OpenCADStudio { if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(name) { dl.flags.locked = locked; } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.locked = locked; } @@ -2678,11 +2707,14 @@ impl OpenCADStudio { // Lock state affects editability, not rendered geometry. self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) {}", - targets.len(), - if locked { "locked" } else { "unlocked" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) {}", + targets.len(), + if locked { "locked" } else { "unlocked" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); self.refresh_properties(); } @@ -2705,8 +2737,11 @@ impl OpenCADStudio { dl.thaw(); } } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.frozen = frozen; } @@ -2714,11 +2749,14 @@ impl OpenCADStudio { self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); - self.command_line.push_output(crate::tf!( - "{} layer(s) {}", - targets.len(), - if frozen { "frozen" } else { "thawed" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{} layer(s) {}", + targets.len(), + if frozen { "frozen" } else { "thawed" } + ) + .as_ref(), + ); self.sync_ribbon_layers(); } } @@ -2756,9 +2794,7 @@ impl OpenCADStudio { self.tabs[i].layers.refresh_sort(); - self.tabs[i] - .scene - .invalidate_layer_dependencies(&targets); + self.tabs[i].scene.invalidate_layer_dependencies(&targets); self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); @@ -2775,7 +2811,7 @@ impl OpenCADStudio { } Task::none() - }, + } Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => { self.on_layer_toggle_vp_freeze(layer_idx, vp_col_idx) @@ -2956,14 +2992,15 @@ impl OpenCADStudio { if let Some(v) = val { let targets = self.layer_row_action_targets(i, idx); for name in &targets { - if let Some(layer) = - self.tabs[i].scene.document.layers.get_mut(name) - { + if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) { layer.transparency = acadrust::types::Transparency::from_percent(v as f64 / 100.0); } - if let Some(pl) = - self.tabs[i].layers.layers.iter_mut().find(|l| &l.name == name) + if let Some(pl) = self.tabs[i] + .layers + .layers + .iter_mut() + .find(|l| &l.name == name) { pl.transparency = v; } @@ -3176,7 +3213,8 @@ impl OpenCADStudio { self.tabs[i].scene.camera.borrow_mut().home_view(r_ucs); } self.tabs[i].scene.camera_generation += 1; - self.command_line.push_output(crate::t!("View: Home").as_ref()); + self.command_line + .push_output(crate::t!("View: Home").as_ref()); Task::none() } @@ -3231,11 +3269,13 @@ impl OpenCADStudio { let mut changed = false; if name.is_empty() || name == "WCS" { self.tabs[i].active_ucs = None; - self.command_line.push_output(crate::t!("UCS: World").as_ref()); + self.command_line + .push_output(crate::t!("UCS: World").as_ref()); changed = true; } else if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { self.tabs[i].active_ucs = Some(named); - self.command_line.push_output(crate::tf!("UCS: {}", name).as_ref()); + self.command_line + .push_output(crate::tf!("UCS: {}", name).as_ref()); changed = true; } if changed { @@ -3283,9 +3323,9 @@ impl OpenCADStudio { .iter() .position(|tab| tab.id == tab_id) .is_some_and(|i| { - self.tabs[i].scene.install_prepared_interaction_index( - epoch, source, wires, index, - ) + self.tabs[i] + .scene + .install_prepared_interaction_index(epoch, source, wires, index) }); if crate::perf::enabled() { crate::perf_record!( @@ -3314,11 +3354,8 @@ impl OpenCADStudio { } else { (queued_wires, screen_height) }; - if let Some(task) = self.prepare_interaction_index_task( - i, - wires, - screen_height, - ) { + if let Some(task) = self.prepare_interaction_index_task(i, wires, screen_height) + { return task; } } @@ -3371,10 +3408,9 @@ impl OpenCADStudio { } else { self.isometric_drafting = true; } - self.command_line.push_output(crate::tf!( - "Isometric plane: {}.", - self.iso_plane.label() - ).as_ref()); + self.command_line.push_output( + crate::tf!("Isometric plane: {}.", self.iso_plane.label()).as_ref(), + ); self.persist_settings_if_changed(); Task::none() } @@ -3437,8 +3473,8 @@ impl OpenCADStudio { } Task::none() } - Message::ResolveOneSketchConflict => { - self.resolve_one_sketch_conflict(); + Message::ResolveOneParametricConflict => { + self.resolve_one_parametric_conflict(); Task::none() } Message::TogglePolar => { @@ -3627,12 +3663,15 @@ impl OpenCADStudio { self.anno_object_scale_target = Some(handles[0]); self.active_modal = Some(crate::app::ModalKind::AnnoObjectScale); } else { - self.command_line - .push_info(crate::t!("The selected object does not support annotation scales.").as_ref()); + self.command_line.push_info( + crate::t!("The selected object does not support annotation scales.") + .as_ref(), + ); } } else { - self.command_line - .push_info(crate::t!("Select one object first, then run OBJECTSCALE.").as_ref()); + self.command_line.push_info( + crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(), + ); } Task::none() } @@ -3645,9 +3684,10 @@ impl OpenCADStudio { if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) { self.push_undo_snapshot(i, "OBJECTSCALE"); let doc = &mut self.tabs[i].scene.document; - let is_member = crate::scene::annotative::object_scale_memberships(doc, entity) - .iter() - .any(|(_, h)| *h == sh); + let is_member = + crate::scene::annotative::object_scale_memberships(doc, entity) + .iter() + .any(|(_, h)| *h == sh); if is_member { crate::scene::annotative::remove_annotation_context_for_scale( doc, entity, sh, @@ -3697,8 +3737,10 @@ impl OpenCADStudio { let i = self.active_tab; let sel = self.scale_manager_selected.clone(); if !sel.is_empty() { - let (paper, drawing) = - self.tabs[i].scene.scale_paper_drawing(&sel).unwrap_or((1.0, 1.0)); + let (paper, drawing) = self.tabs[i] + .scene + .scale_paper_drawing(&sel) + .unwrap_or((1.0, 1.0)); let name = self.unique_scale_name(&sel); if self.tabs[i].scene.add_scale(&name, paper, drawing) { self.load_scale_editor(&name); @@ -3723,8 +3765,10 @@ impl OpenCADStudio { if let Some(old) = self.scale_rename.take() { let new = self.scale_rename_buf.trim().to_string(); if !new.is_empty() && !new.eq_ignore_ascii_case(&old) { - let (paper, drawing) = - self.tabs[i].scene.scale_paper_drawing(&old).unwrap_or((1.0, 1.0)); + let (paper, drawing) = self.tabs[i] + .scene + .scale_paper_drawing(&old) + .unwrap_or((1.0, 1.0)); // Only fall back to add_scale for a built-in fallback (no // real object); never for a real scale whose rename was // rejected (name collision) — that would duplicate it. @@ -3839,8 +3883,7 @@ impl OpenCADStudio { Message::ToggleQuickProperties => { self.quick_properties ^= true; if self.quick_properties { - self.quick_properties_anchor = - self.tabs[self.active_tab].last_cursor_screen; + self.quick_properties_anchor = self.tabs[self.active_tab].last_cursor_screen; } self.save_config(); Task::none() @@ -3853,10 +3896,8 @@ impl OpenCADStudio { } Message::CycleSelect(handle) => { // Add the picked object to the current selection (accumulate). - let quick_properties_anchor = self - .cycle_candidates - .as_ref() - .map(|(point, _)| *point); + let quick_properties_anchor = + self.cycle_candidates.as_ref().map(|(point, _)| *point); self.cycle_candidates = None; let i = self.active_tab; self.tabs[i].scene.set_hover_highlight(None); @@ -4082,7 +4123,8 @@ impl OpenCADStudio { self.close_active_modal(); self.snap_popup_open = false; } else { - let st = crate::ui::window::drafting_settings::DraftingSettingsState::from_app(self); + let st = + crate::ui::window::drafting_settings::DraftingSettingsState::from_app(self); self.drafting_settings_saved = Some(st.clone()); self.drafting_settings_state = Some(st); self.drafting_settings_close_confirm = false; @@ -4622,9 +4664,8 @@ impl OpenCADStudio { self.focus_cmd_input() } Text::EmptyOrUnsupported => { - self.command_line.push_error( - crate::tr!("clipboard", "no-supported-content").as_ref(), - ); + self.command_line + .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); Task::none() } Text::Unavailable => { @@ -4742,8 +4783,9 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; let count = self.tabs[i].scene.invert_selection(); - self.command_line - .push_output(crate::tf!("Invert Selection: {} object(s) selected.", count).as_ref()); + self.command_line.push_output( + crate::tf!("Invert Selection: {} object(s) selected.", count).as_ref(), + ); self.refresh_properties(); Task::none() } @@ -4790,11 +4832,12 @@ impl OpenCADStudio { if matches!(scope, crate::app::QSelectScope::CurrentSelection) { state.append = false; } - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4807,9 +4850,7 @@ impl OpenCADStudio { .qselect .as_ref() .map_or(crate::app::QSelectScope::CurrentSpace, |state| state.scope); - let properties = self.tabs[i] - .scene - .qselect_properties(t.as_deref(), scope); + let properties = self.tabs[i].scene.qselect_properties(t.as_deref(), scope); if let Some(state) = self.qselect.as_mut() { let kept_property = state.property.as_ref().and_then(|selected| { properties @@ -4822,11 +4863,12 @@ impl OpenCADStudio { state.property = kept_property; state.value.clear(); state.error = None; - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4838,11 +4880,12 @@ impl OpenCADStudio { state.property = p; state.value.clear(); state.error = None; - if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) - && !state.property.as_ref().is_some_and(|property| { - matches!(property.editor, crate::app::QSelectValueEditor::Number) - }) - { + if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) { state.operator = crate::app::QSelectOp::Eq; } } @@ -4907,7 +4950,8 @@ impl OpenCADStudio { } else { match &property.editor { crate::app::QSelectValueEditor::Number - if crate::entities::common::parse_f64(&state.value).is_none() => + if crate::entities::common::parse_f64(&state.value) + .is_none() => { Some(crate::t!("Enter a valid number.").into_owned()) } @@ -4944,8 +4988,7 @@ impl OpenCADStudio { state.operator, &state.value, state.mode, - state.append - && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), + state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); @@ -4985,9 +5028,7 @@ impl OpenCADStudio { // re-tessellate so the change shows immediately (issue #231 // class). self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_line_weight(entity, lw); } }); @@ -5007,12 +5048,8 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { - crate::scene::view::dispatch::apply_common_prop( - entity, "layer", &layer, - ); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer); } }); Task::none() @@ -5027,9 +5064,7 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_color(entity, color); } }); @@ -5051,9 +5086,7 @@ impl OpenCADStudio { return Task::none(); } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { crate::scene::view::dispatch::apply_line_weight(entity, lw); } }); @@ -5095,12 +5128,8 @@ impl OpenCADStudio { return task; } self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { - crate::scene::view::dispatch::apply_common_prop( - entity, "linetype", <, - ); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <); } }); Task::none() @@ -5120,11 +5149,10 @@ impl OpenCADStudio { panel.color_picker_open = false; panel.open_color_field = None; panel.edit_choice_open = false; - panel.hatch_pattern_focus = - crate::ui::properties::filtered_hatch_patterns("") - .iter() - .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) - .unwrap_or(0); + panel.hatch_pattern_focus = crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(¤t)) + .unwrap_or(0); return iced::widget::operation::focus(iced::widget::Id::new( "hatch-pattern-search", )); @@ -5244,8 +5272,7 @@ impl OpenCADStudio { }; let Some((sx, sy, sz)) = scales else { return }; let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12; - let checked = - eq && !app.props_asym_scale.contains(&handle.value()); + let checked = eq && !app.props_asym_scale.contains(&handle.value()); if checked { app.props_asym_scale.insert(handle.value()); } else { @@ -5266,16 +5293,17 @@ impl OpenCADStudio { else { return; }; - let table_style = table.table_style_handle.and_then(|style_handle| { - document.objects.get(&style_handle).and_then(|object| { - match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) + let table_style = + table.table_style_handle.and_then(|style_handle| { + document.objects.get(&style_handle).and_then(|object| { + match object { + acadrust::objects::ObjectType::TableStyle( + style, + ) => Some(style), + _ => None, } - _ => None, - } - }) - }); + }) + }); let current = if field == "tbl_title_suppressed" { crate::entities::table::resolved_title_suppressed( table, @@ -5331,7 +5359,11 @@ impl OpenCADStudio { Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), Some(acadrust::EntityType::PolygonMesh(p)) => p.vertices.len(), Some(acadrust::EntityType::Face3D(face)) => { - if face.is_triangle() { 3 } else { 4 } + if face.is_triangle() { + 3 + } else { + 4 + } } Some(acadrust::EntityType::Polyline3D(p)) => { crate::entities::polyline::polyline3d_control_vertex_count(p) @@ -5472,8 +5504,7 @@ impl OpenCADStudio { m.background_color = color.clone(); // Picking a colour turns the background on in Fill // mode (specific colour), preserving the frame bit. - m.background_fill_flags = - (m.background_fill_flags & !0x02) | 0x01; + m.background_fill_flags = (m.background_fill_flags & !0x02) | 0x01; } Some(acadrust::EntityType::Hatch(h)) => { crate::entities::hatch::set_background_color(h, &color); @@ -5579,8 +5610,7 @@ impl OpenCADStudio { } if matches!( field.as_str(), - "line_color" | "text_color" | "block_content_color" - | "background_fill_color" + "line_color" | "text_color" | "block_content_color" | "background_fill_color" ) { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if let Some(acadrust::EntityType::MultiLeader(leader)) = @@ -5807,8 +5837,9 @@ impl OpenCADStudio { Message::LayoutManagerOpen => { let i = self.active_tab; if self.tabs[i].is_start { - self.command_line - .push_info(crate::t!("Open or create a drawing to manage layouts.").as_ref()); + self.command_line.push_info( + crate::t!("Open or create a drawing to manage layouts.").as_ref(), + ); return Task::none(); } let current = self.tabs[i].scene.current_layout.clone(); @@ -5846,7 +5877,8 @@ impl OpenCADStudio { self.command_line .push_error(crate::t!("Cannot rename the Model layout.").as_ref()); } else if new_name.is_empty() { - self.command_line.push_error(crate::t!("Layout name cannot be empty.").as_ref()); + self.command_line + .push_error(crate::t!("Layout name cannot be empty.").as_ref()); } else if new_name == old_name { // no-op } else { @@ -5857,8 +5889,9 @@ impl OpenCADStudio { } self.layout_manager_selected = new_name.clone(); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Layout renamed: '{old_name}' → '{new_name}'").as_ref()); + self.command_line.push_output( + crate::tf!("Layout renamed: '{old_name}' → '{new_name}'").as_ref(), + ); } Task::none() } @@ -5883,7 +5916,9 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("Layout '{name}' created.").as_ref()); } - Err(e) => self.command_line.push_error(crate::tf!("LAYOUT: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("LAYOUT: {e}").as_ref()), } Task::none() } @@ -5966,8 +6001,7 @@ impl OpenCADStudio { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = theme.to_string(); - self.ui_theme.palette = - crate::app::config::UiThemePalette::from_iced(theme.seed()); + self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.active_theme = theme; self.sync_model_space_theme(true); @@ -6153,10 +6187,9 @@ impl OpenCADStudio { } Message::AliasEditorApply => { self.apply_alias_editor_rows(); - self.command_line.push_info(crate::tf!( - "{} alias(es) applied.", - self.command_aliases.len() - ).as_ref()); + self.command_line.push_info( + crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), + ); Task::none() } @@ -6167,7 +6200,10 @@ impl OpenCADStudio { .scene .named_parameters() .iter() - .map(|p| crate::ui::window::named_parameters::ParamEditorRow { name: p.name.clone(), formula: p.source.clone() }) + .map(|p| crate::ui::window::named_parameters::ParamEditorRow { + name: p.name.clone(), + formula: p.source.clone(), + }) .collect(); self.active_modal = Some(super::ModalKind::NamedParameters); Task::none() @@ -6183,7 +6219,8 @@ impl OpenCADStudio { Task::none() } Message::NamedParametersAdd => { - self.named_parameter_editor_rows.push(crate::ui::window::named_parameters::ParamEditorRow::default()); + self.named_parameter_editor_rows + .push(crate::ui::window::named_parameters::ParamEditorRow::default()); Task::none() } Message::NamedParametersRemove(idx) => { @@ -6199,7 +6236,11 @@ impl OpenCADStudio { // ── Parameters / Constraints sections embedded in the // Properties panel ────────────────────────────────────────────── - Message::PropParamInput { index, field, value } => { + Message::PropParamInput { + index, + field, + value, + } => { self.tabs[self.active_tab] .properties .edit_buf @@ -6283,8 +6324,7 @@ impl OpenCADStudio { } Message::DefaultSaveFormatChanged(format) => { - self.default_save_format = - crate::io::canonical_save_format(&format).to_string(); + self.default_save_format = crate::io::canonical_save_format(&format).to_string(); self.persist_settings_if_changed(); Task::none() } @@ -6294,9 +6334,7 @@ impl OpenCADStudio { self.saved_custom_palette = Some(self.ui_theme.palette); } self.ui_theme.name = name; - if let Some(theme) = - crate::app::config::builtin_theme(&self.ui_theme.name) - { + if let Some(theme) = crate::app::config::builtin_theme(&self.ui_theme.name) { self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(theme.seed()); self.theme_color_inputs = self.ui_theme.palette.hex_values(); @@ -6521,7 +6559,8 @@ impl OpenCADStudio { Message::ClipromptLinesChanged(lines) => { let lines = crate::app::settings::clamp_clipromptlines(lines); self.cliprompt_lines = lines; - self.command_line.set_cliprompt_lines(lines.clamp(0, 50) as u8); + self.command_line + .set_cliprompt_lines(lines.clamp(0, 50) as u8); self.persist_settings_if_changed(); Task::none() } @@ -6663,9 +6702,7 @@ impl OpenCADStudio { } Message::ShowHistChanged(mode) => { - self.set_drawing_var(|header| { - header.show_solid_history = mode.clamp(0, 2) - }); + self.set_drawing_var(|header| header.show_solid_history = mode.clamp(0, 2)); let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { tab.scene.bump_geometry(); @@ -6741,11 +6778,6 @@ impl OpenCADStudio { } Task::none() } - Message::WriteDwgNativeConstraintsChanged(enabled) => { - self.write_dwg_native_constraints = enabled; - self.persist_settings_if_changed(); - Task::none() - } Message::ShowConstraintValuesChanged(enabled) => { self.show_constraint_values = enabled; self.persist_settings_if_changed(); @@ -6833,9 +6865,9 @@ impl OpenCADStudio { self.close_active_modal(); return Task::none(); }; - let model_bg = self.default_bg_color.unwrap_or_else(|| { - self.model_space.resolve_model_bg(&self.active_theme) - }); + let model_bg = self + .default_bg_color + .unwrap_or_else(|| self.model_space.resolve_model_bg(&self.active_theme)); #[cfg(not(target_arch = "wasm32"))] if let Some(path) = opening.source_path.clone() { let current_fingerprint = @@ -6914,10 +6946,8 @@ impl OpenCADStudio { let declined = self.opening.take(); self.close_active_modal(); if let Some(opening) = declined { - self.command_line.push_info(crate::tf!( - "Recovery cancelled: \"{}\"", - opening.name - ).as_ref()); + self.command_line + .push_info(crate::tf!("Recovery cancelled: \"{}\"", opening.name).as_ref()); } self.drain_pending_open() } @@ -6950,9 +6980,9 @@ impl OpenCADStudio { { if let Some(path) = &report.log_path { if let Err(error) = crate::sys::reveal_in_file_manager(path) { - self.command_line.push_error(crate::tf!( - "Could not show recovery log: {error}" - ).as_ref()); + self.command_line.push_error( + crate::tf!("Could not show recovery log: {error}").as_ref(), + ); } } } @@ -7089,13 +7119,11 @@ impl OpenCADStudio { if first_measurement { let initial_width = self.mtext_editor.as_ref().and_then(|editor| { editor.editing.is_none().then(|| { - (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD) - .max(80.0) + (size.width - 2.0 * super::view::overlay::MTEXT_PREVIEW_PAD).max(80.0) / editor.preview_scale() }) }); - if let (Some(editor), Some(width)) = - (self.mtext_editor.as_mut(), initial_width) + if let (Some(editor), Some(width)) = (self.mtext_editor.as_mut(), initial_width) { editor.rect_width = f64::from(width.max(1e-6)); self.rebuild_mtext_preview(); @@ -7231,7 +7259,9 @@ impl OpenCADStudio { .external_plugins .iter() .find_map(|plugin| plugin.repository.clone()) - .or_else(|| self.plugin_registry.first().map(|entry| entry.repo.clone())) + .or_else(|| { + self.plugin_registry.first().map(|entry| entry.repo.clone()) + }) .or_else(|| self.plugin_repos.first().cloned()); } if let Some(repo) = self.selected_plugin_repo.clone() { @@ -7271,13 +7301,15 @@ impl OpenCADStudio { crate::plugin::external::normalize_repository(&self.plugin_repo_input) else { self.marketplace_status = - crate::t!("Enter a GitHub URL or repository in owner/repo format.").into_owned(); + crate::t!("Enter a GitHub URL or repository in owner/repo format.") + .into_owned(); return Task::none(); }; if self.plugin_repos.contains(&repo) || self.plugin_registry.iter().any(|entry| entry.repo == repo) { - self.marketplace_status = crate::tf!("{repo} is already in the catalog.").into_owned(); + self.marketplace_status = + crate::tf!("{repo} is already in the catalog.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) @@ -7291,7 +7323,8 @@ impl OpenCADStudio { .iter() .any(|plugin| plugin.repository.as_deref() == Some(repo.as_str())) { - self.marketplace_status = crate::tf!("{repo} is already installed.").into_owned(); + self.marketplace_status = + crate::tf!("{repo} is already installed.").into_owned(); self.selected_plugin_repo = Some(repo.clone()); if !self.plugin_readmes.contains_key(&repo) && self.plugin_readme_loading.insert(repo.clone()) @@ -7449,12 +7482,14 @@ impl OpenCADStudio { .entry(repo.clone()) .or_insert_with(|| first.tag.clone()); } - if self.marketplace_status == crate::tf!("Fetching releases for {repo}…").into_owned() { - self.marketplace_status = - crate::tf!( - "Repository added. {} installable release(s) found.", - releases.len() - ).into_owned(); + if self.marketplace_status + == crate::tf!("Fetching releases for {repo}…").into_owned() + { + self.marketplace_status = crate::tf!( + "Repository added. {} installable release(s) found.", + releases.len() + ) + .into_owned(); } self.repo_release_tags.insert(repo, releases); Task::none() @@ -7500,7 +7535,8 @@ impl OpenCADStudio { self.install_task(repo, tag) } Message::PluginInstalled(Ok(id)) => { - self.marketplace_status = crate::tf!("Installed '{id}'. Restart to load it.").into_owned(); + self.marketplace_status = + crate::tf!("Installed '{id}'. Restart to load it.").into_owned(); self.plugin_load_errors.remove(&id); #[cfg(not(target_arch = "wasm32"))] { @@ -7519,7 +7555,8 @@ impl OpenCADStudio { // and allows the package directory to be deleted. if !crate::plugin::external::remove_plugin(&id) { self.marketplace_status = - crate::tf!("Uninstall failed: plugin '{id}' did not stop in time").into_owned(); + crate::tf!("Uninstall failed: plugin '{id}' did not stop in time") + .into_owned(); return Task::none(); } match crate::plugin::external::uninstall(&id) { @@ -7532,7 +7569,8 @@ impl OpenCADStudio { self.external_plugins = crate::plugin::external::discover(); } Err(e) => { - self.marketplace_status = crate::tf!("Uninstall failed: {e}").into_owned(); + self.marketplace_status = + crate::tf!("Uninstall failed: {e}").into_owned(); } } } @@ -7644,8 +7682,9 @@ impl OpenCADStudio { Message::MspaceCommand => { let i = self.active_tab; if self.tabs[i].scene.current_layout == "Model" { - self.command_line - .push_error(crate::t!("MS is only available in paper space layouts.").as_ref()); + self.command_line.push_error( + crate::t!("MS is only available in paper space layouts.").as_ref(), + ); return Task::none(); } if self.tabs[i].scene.active_viewport.is_some() { @@ -7743,14 +7782,7 @@ impl OpenCADStudio { version, bounds, screenshot, - } => self.on_web_save_screenshot( - tab_id, - filename, - ext, - version, - bounds, - screenshot, - ), + } => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot), #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), @@ -7800,10 +7832,8 @@ impl OpenCADStudio { Task::none() } Message::UpdateNoticeOpenRelease => { - let open = crate::sys::open_url( - crate::io::update_check::RELEASES_PAGE, - self.main_window, - ); + let open = + crate::sys::open_url(crate::io::update_check::RELEASES_PAGE, self.main_window); self.close_active_modal(); open } @@ -7991,7 +8021,8 @@ impl OpenCADStudio { Task::none() } Message::PrintResult(Err(e)) => { - self.command_line.push_error(crate::tf!("Print failed: {e}").as_ref()); + self.command_line + .push_error(crate::tf!("Print failed: {e}").as_ref()); Task::none() } @@ -8002,21 +8033,27 @@ impl OpenCADStudio { Message::PlotStyleLoaded(Some(table)) => { if table.is_stb { self.command_line.push_error( - crate::t!("Named plot style tables are not supported by the vector plotter.").as_ref(), + crate::t!( + "Named plot style tables are not supported by the vector plotter." + ) + .as_ref(), ); return Task::none(); } self.plot_dialog.style_name = table.name.clone(); self.plot_dialog.style_missing = false; - self.command_line.push_output(crate::tf!( - "Plot style '{}' loaded ({} color entries).", - table.name, - table - .aci_entries - .iter() - .filter(|e| e.color.is_some()) - .count() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Plot style '{}' loaded ({} color entries).", + table.name, + table + .aci_entries + .iter() + .filter(|e| e.color.is_some()) + .count() + ) + .as_ref(), + ); self.active_plot_style = Some(table); self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); Task::none() @@ -8026,7 +8063,8 @@ impl OpenCADStudio { self.active_plot_style = None; self.plot_dialog.style_name.clear(); self.plot_dialog.style_missing = false; - self.command_line.push_output(crate::t!("Plot style table cleared.").as_ref()); + self.command_line + .push_output(crate::t!("Plot style table cleared.").as_ref()); Task::none() } @@ -8137,62 +8175,59 @@ impl OpenCADStudio { Message::PlotStylePanelApply => self.on_plot_style_panel_apply(), - Message::PlotStylePanelSaveDirect => { - if self.active_plot_style.is_none() { - self.command_line.push_error( - crate::t!("No plot style table loaded. Load or create one first.").as_ref(), - ); - return Task::none(); - } - - #[cfg(not(target_arch = "wasm32"))] - { - let table = self.active_plot_style.as_ref().expect("checked above"); - let table_name = table.name.clone(); - - let result = crate::io::plot_style::ensure_plot_styles_dir().and_then(|dir| { - let path = dir.join(&table_name); - table.save(&path)?; - Ok(path) - }); - - match result { - Ok(path) => { - self.plot_dialog.style_name = table_name; - self.plot_dialog.style_missing = false; - self.plot_dialog.plot_styles = - crate::io::plot_style::available_ctb_names(); - self.tabs[self.active_tab] - .scene - .invalidate_display_plot_style(); - - self.command_line.push_output( - crate::tf!( - "Plot style table saved to \"{}\".", - path.display() - ) - .as_ref(), - ); - } - Err(error) => { - self.command_line - .push_error(crate::tf!("Save error: {error}").as_ref()); - } + Message::PlotStylePanelSaveDirect => { + if self.active_plot_style.is_none() { + self.command_line.push_error( + crate::t!("No plot style table loaded. Load or create one first.").as_ref(), + ); + return Task::none(); } - Task::none() + #[cfg(not(target_arch = "wasm32"))] + { + let table = self.active_plot_style.as_ref().expect("checked above"); + let table_name = table.name.clone(); + + let result = crate::io::plot_style::ensure_plot_styles_dir().and_then(|dir| { + let path = dir.join(&table_name); + table.save(&path)?; + Ok(path) + }); + + match result { + Ok(path) => { + self.plot_dialog.style_name = table_name; + self.plot_dialog.style_missing = false; + self.plot_dialog.plot_styles = + crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); + + self.command_line.push_output( + crate::tf!("Plot style table saved to \"{}\".", path.display()) + .as_ref(), + ); + } + Err(error) => { + self.command_line + .push_error(crate::tf!("Save error: {error}").as_ref()); + } + } + + Task::none() + } + + #[cfg(target_arch = "wasm32")] + { + // Browsers cannot overwrite a local file directly, so fall back + // to the existing Save As flow. + self.on_plot_style_panel_save() + } } - #[cfg(target_arch = "wasm32")] - { - // Browsers cannot overwrite a local file directly, so fall back - // to the existing Save As flow. - self.on_plot_style_panel_save() - } - } - - Message::PlotStylePanelSave => self.on_plot_style_panel_save(), - Message::PlotStylePanelSavePath(Some(path)) => { + Message::PlotStylePanelSave => self.on_plot_style_panel_save(), + Message::PlotStylePanelSavePath(Some(path)) => { let path = if path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("ctb")) @@ -8219,12 +8254,14 @@ impl OpenCADStudio { self.tabs[self.active_tab] .scene .invalidate_display_plot_style(); - self.command_line.push_output(crate::tf!( - "Plot style table saved to \"{}\".", - path.display() - ).as_ref()); + self.command_line.push_output( + crate::tf!("Plot style table saved to \"{}\".", path.display()) + .as_ref(), + ); } - Err(e) => self.command_line.push_error(crate::tf!("Save error: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("Save error: {e}").as_ref()), } } Task::none() @@ -8671,18 +8708,28 @@ impl OpenCADStudio { match field { "fill" => style.flags.fill_on = !style.flags.fill_on, "joints" => style.flags.display_joints = !style.flags.display_joints, - "start_square" => style.flags.start_square_cap = !style.flags.start_square_cap, - "start_inner" => style.flags.start_inner_arcs_cap = !style.flags.start_inner_arcs_cap, + "start_square" => { + style.flags.start_square_cap = !style.flags.start_square_cap + } + "start_inner" => { + style.flags.start_inner_arcs_cap = !style.flags.start_inner_arcs_cap + } "start_round" => style.flags.start_round_cap = !style.flags.start_round_cap, "end_square" => style.flags.end_square_cap = !style.flags.end_square_cap, - "end_inner" => style.flags.end_inner_arcs_cap = !style.flags.end_inner_arcs_cap, + "end_inner" => { + style.flags.end_inner_arcs_cap = !style.flags.end_inner_arcs_cap + } "end_round" => style.flags.end_round_cap = !style.flags.end_round_cap, _ => {} } } Task::none() } - Message::MlStyleElementEdit { index, field, value } => { + Message::MlStyleElementEdit { + index, + field, + value, + } => { if let Some(element) = self.mln_elements.get_mut(index) { match field { "offset" => element[0] = value, @@ -8832,12 +8879,11 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.document.header.current_dimstyle_name = - self.dimstyle_selected.clone(); + self.dimstyle_selected.clone(); self.sync_ribbon_styles(); - self.command_line.push_output(crate::tf!( - "Current dim style set to '{}'.", - self.dimstyle_selected - ).as_ref()); + self.command_line.push_output( + crate::tf!("Current dim style set to '{}'.", self.dimstyle_selected).as_ref(), + ); Task::none() } Message::DimStyleDialogDelete => { @@ -8968,8 +9014,7 @@ impl OpenCADStudio { // are logical CAD states rather than reusable colours. if matches!( &color, - acadrust::types::Color::Index(_) - | acadrust::types::Color::Rgb { .. } + acadrust::types::Color::Index(_) | acadrust::types::Color::Rgb { .. } ) { // No duplicates: selecting an existing colour moves it to the front. if let Some(pos) = self.recent_colors.iter().position(|c| c == &color) { @@ -9243,7 +9288,10 @@ mod free_text_entry_tests { assert_eq!(app.command_line.input, "LIN"); // …and a pasted multi-token line runs as a command, not as text. let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into())); - assert!(app.command_line.input.is_empty(), "Space submitted the line"); + assert!( + app.command_line.input.is_empty(), + "Space submitted the line" + ); assert_eq!(app.text_entry_mode(), TextEntryMode::Command); } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c57bb947c..ae3372607 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -100,12 +100,10 @@ fn is_added_polyline_vertex( ) -> bool { match (original, current) { (AcadEntityType::LwPolyline(before), AcadEntityType::LwPolyline(after)) => { - after.vertices.len() == before.vertices.len() + 1 - && vertex_id < after.vertices.len() + after.vertices.len() == before.vertices.len() + 1 && vertex_id < after.vertices.len() } (AcadEntityType::Polyline2D(before), AcadEntityType::Polyline2D(after)) => { - after.vertices.len() == before.vertices.len() + 1 - && vertex_id < after.vertices.len() + after.vertices.len() == before.vertices.len() + 1 && vertex_id < after.vertices.len() } _ => false, } @@ -253,9 +251,7 @@ impl OpenCADStudio { eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { - let intersection = snap.filter(|hit| { - hit.snap_type == crate::snap::SnapType::Intersection - }); + let intersection = snap.filter(|hit| hit.snap_type == crate::snap::SnapType::Intersection); // Ordinary object snaps suppress OTRACK. Extension and Intersection are // exceptions: both may lie on an active tracking vector. @@ -269,22 +265,15 @@ impl OpenCADStudio { } let required_crossing_ray = match snap { - Some(extension) - if extension.snap_type == crate::snap::SnapType::Extension => - { - Some(( - extension.extension_origin?, - extension.extension_dir?, - )) + Some(extension) if extension.snap_type == crate::snap::SnapType::Extension => { + Some((extension.extension_origin?, extension.extension_dir?)) } _ => None, }; // When Intersection has already won OSNAP selection, test OTRACK at the // exact intersection rather than at the free cursor position. - let track_cursor = intersection - .map(|hit| hit.world) - .unwrap_or(cursor); + let track_cursor = intersection.map(|hit| hit.world).unwrap_or(cursor); let step = (self.polar_mode && drafting).then_some(self.polar_increment_deg); let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(tab); @@ -412,8 +401,8 @@ impl OpenCADStudio { if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() { self.show_grid = grid_on; } - let ortho = self.tabs[i].scene.active_camera_projection() - == crate::scene::Projection::Orthographic; + let ortho = + self.tabs[i].scene.active_camera_projection() == crate::scene::Projection::Orthographic; self.ribbon.set_ortho(ortho); } @@ -498,9 +487,12 @@ impl OpenCADStudio { /// Reproject the cursor onto that profile before using it as a drag anchor /// or looking for its supporting solid face. fn profile_pick_point( - &self, i: usize, handle: Handle, + &self, + i: usize, + handle: Handle, edit_cam: &Option, - cursor: iced::Point, bounds: iced::Rectangle, + cursor: iced::Point, + bounds: iced::Rectangle, ) -> Option { let entity = self.tabs[i].scene.document.get_entity(handle)?; let (plane, _, _) = crate::scene::model::presspull_model::profile_geometry(entity)?; @@ -508,7 +500,11 @@ impl OpenCADStudio { let origin = glam::DVec3::from_array(plane.origin); let point = match edit_cam { Some(camera) => camera.pick_on_plane(cursor, bounds, normal, origin), - None => self.tabs[i].scene.camera.borrow().pick_on_plane(cursor, bounds, normal, origin), + None => self.tabs[i] + .scene + .camera + .borrow() + .pick_on_plane(cursor, bounds, normal, origin), }; point.is_finite().then_some(point) } @@ -574,9 +570,7 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .filter(|(owner, grip)| { - self.tabs[i].hot_grips.contains(&(*owner, grip.id)) - }) + .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) .map(|(owner, grip)| GripTarget { handle: owner, @@ -600,9 +594,7 @@ impl OpenCADStudio { .copied() .zip(self.tabs[i].selected_grips.iter()) .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) - .filter(|(_, grip)| { - (grip.world - world).length_squared() <= epsilon_sq - }) + .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { handle: owner, grip_id: grip.id, @@ -876,8 +868,7 @@ impl OpenCADStudio { // viewport's screen rectangle in a paper layout. let i = self.active_tab; if self.tabs[i].scene.active_viewport != expected_viewport - || (expected_viewport.is_none() - && self.tabs[i].scene.current_layout != "Model") + || (expected_viewport.is_none() && self.tabs[i].scene.current_layout != "Model") { return Task::none(); } @@ -1066,9 +1057,7 @@ impl OpenCADStudio { let zoom_delta = -dy * 0.03; if self.tabs[i].scene.active_viewport.is_some() { self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds); - self.tabs[i] - .scene - .zoom_active_viewport(zoom_delta, None); + self.tabs[i].scene.zoom_active_viewport(zoom_delta, None); } else { let local = iced::Point { x: p.x - bounds.x, @@ -1295,7 +1284,7 @@ impl OpenCADStudio { // dimension re-tessellates) and a Square insertion grip (so // an MTEXT width handle, a Triangle, still re-tessellates so // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&edited_handles); + let snap = self.tabs[i].scene.wire_models_for(&edited_handles); // Preserve the exact geometry from the instant the grip drag began. // This snapshot is visual/reference-only: do NOT append it to @@ -1303,12 +1292,8 @@ impl OpenCADStudio { self.grip_reference_wires = snap.clone(); // The engaged grip is always an intentional OTRACK reference. - self.snapper.acquire_grip_tracking_point( - grip.origin_world, - &self.grip_reference_wires, - ); - - + self.snapper + .acquire_grip_tracking_point(grip.origin_world, &self.grip_reference_wires); self.grip_text_verts = snap .iter() @@ -1319,9 +1304,7 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .find(|(owner, grip_def)| { - *owner == grip.handle && grip_def.id == grip.grip_id - }) + .find(|(owner, grip_def)| *owner == grip.handle && grip_def.id == grip.grip_id) .map(|(_, grip_def)| { grip_def.shape == crate::scene::model::object::GripShape::Square }) @@ -1384,37 +1367,36 @@ impl OpenCADStudio { // Its result is never used to place/move the grip. It exists only so the // user can hover an endpoint/midpoint/etc. of the original geometry and // acquire it for OTRACK exactly like ordinary drawing geometry. - let reference_snap_hit = if self.snapper.tracking_active() - && !self.grip_reference_wires.is_empty() - { - self.snapper - .snap( - raw, - p, - &self.grip_reference_wires, - view_rot, - eye, - bounds, - go, - gr, - None, - ) - .filter(|hit| { - matches!( - hit.snap_type, - crate::snap::SnapType::Endpoint - | crate::snap::SnapType::Midpoint - | crate::snap::SnapType::Center - | crate::snap::SnapType::Node - | crate::snap::SnapType::Quadrant - | crate::snap::SnapType::Intersection - | crate::snap::SnapType::Insertion - | crate::snap::SnapType::ApparentIntersection + let reference_snap_hit = + if self.snapper.tracking_active() && !self.grip_reference_wires.is_empty() { + self.snapper + .snap( + raw, + p, + &self.grip_reference_wires, + view_rot, + eye, + bounds, + go, + gr, + None, ) - }) - } else { - None - }; + .filter(|hit| { + matches!( + hit.snap_type, + crate::snap::SnapType::Endpoint + | crate::snap::SnapType::Midpoint + | crate::snap::SnapType::Center + | crate::snap::SnapType::Node + | crate::snap::SnapType::Quadrant + | crate::snap::SnapType::Intersection + | crate::snap::SnapType::Insertion + | crate::snap::SnapType::ApparentIntersection + ) + }) + } else { + None + }; // The visible marker should normally describe the real snap that is driving // the cursor. However, when there is no real object snap (or only Grid), show @@ -1432,12 +1414,9 @@ impl OpenCADStudio { // // IMPORTANT: this combined set is used ONLY for OTRACK acquisition. // It is never passed to the normal movement snap above. - let mut tracking_candidates: Vec<_> = - snap_candidates.iter().cloned().collect(); + let mut tracking_candidates: Vec<_> = snap_candidates.iter().cloned().collect(); - tracking_candidates.extend( - self.grip_reference_wires.iter().cloned() - ); + tracking_candidates.extend(self.grip_reference_wires.iter().cloned()); // Prefer a genuine drawing snap when it is an acquisition-capable point. // Otherwise let the frozen-reference snap drive the dwell acquisition. @@ -1467,16 +1446,7 @@ impl OpenCADStudio { ); let axis_lock = self.active_axis_lock(i, raw, base, true); let otrack_hit = if axis_lock.is_none() { - self.active_otrack_hit( - i, - raw, - snap_hit, - Some(base), - true, - view_rot, - eye, - bounds, - ) + self.active_otrack_hit(i, raw, snap_hit, Some(base), true, view_rot, eye, bounds) } else { None }; @@ -1486,9 +1456,7 @@ impl OpenCADStudio { let dir = (target - base).try_normalize()?; Some((base, dir)) }); - self.otrack_active = otrack_hit - .map(|hit| (hit.base, hit.dir)) - .or(drafting_guide); + self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide); self.otrack_kind = otrack_hit.map(|hit| hit.kind); @@ -1535,28 +1503,20 @@ impl OpenCADStudio { if let Some(axis) = grip.axis { let acquired_point = otrack_hit.is_some() - || snap_hit.is_some_and(|hit| { - hit.snap_type != crate::snap::SnapType::Grid - }); + || snap_hit.is_some_and(|hit| hit.snap_type != crate::snap::SnapType::Grid); if acquired_point { if let Some(axis) = axis.try_normalize() { // An axis grip still moves on its construction axis, but // an acquired object point supplies the exact coordinate // along that axis. This lets height and draft arrows seat // back onto existing surface/solid geometry. - snapped = grip.origin_world - + axis * (snapped - grip.origin_world).dot(axis); + snapped = + grip.origin_world + axis * (snapped - grip.origin_world).dot(axis); } } else { - snapped = cursor_on_projected_axis( - p, - bounds, - view_rot, - eye, - grip.origin_world, - axis, - ) - .unwrap_or(snapped); + snapped = + cursor_on_projected_axis(p, bounds, view_rot, eye, grip.origin_world, axis) + .unwrap_or(snapped); } } @@ -1569,8 +1529,7 @@ impl OpenCADStudio { // Project the grip's original position into full-canvas coordinates. // Dynamic Input uses this as the polar Distance/Angle anchor. - let anchor_ndc = - view_rot.project_point3((grip.origin_world - eye).as_vec3()); + let anchor_ndc = view_rot.project_point3((grip.origin_world - eye).as_vec3()); self.tabs[i].last_point_screen = Some(Point::new( (anchor_ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x, @@ -1607,9 +1566,7 @@ impl OpenCADStudio { GripEditMode::Lengthen => { Some(crate::scene::model::object::GripMenuAction::Lengthen) } - GripEditMode::Radius => { - Some(crate::scene::model::object::GripMenuAction::Radius) - } + GripEditMode::Radius => Some(crate::scene::model::object::GripMenuAction::Radius), GripEditMode::ArcLength => { Some(crate::scene::model::object::GripMenuAction::ArcLength) } @@ -1685,13 +1642,12 @@ impl OpenCADStudio { snapped, ); if let Some(value) = value { - let origin_value = - crate::scene::view::dispatch::grip_menu_point_value( - &original, - grip.grip_id, - action, - grip.origin_world, - ); + let origin_value = crate::scene::view::dispatch::grip_menu_point_value( + &original, + grip.grip_id, + action, + grip.origin_world, + ); let mut candidate = original.clone(); crate::entities::traits::EntityTypeOps::apply_grip_menu_value( &mut candidate, @@ -1722,10 +1678,8 @@ impl OpenCADStudio { // Arc point grips are coupled: moving one point must rebuild the // circle from the drag-start start/middle/end set, otherwise the // supposedly fixed points drift a little on every mouse event. - let mut arc_grip_edits: rustc_hash::FxHashMap< - Handle, - Vec<(usize, glam::DVec3)>, - > = rustc_hash::FxHashMap::default(); + let mut arc_grip_edits: rustc_hash::FxHashMap> = + rustc_hash::FxHashMap::default(); for (handle, grip_id, apply) in &actions { if let GripApply::Absolute(point) = apply { if (1..=3).contains(grip_id) { @@ -1749,12 +1703,8 @@ impl OpenCADStudio { let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) else { continue; }; - if crate::scene::view::dispatch::refit_arc_grips( - current, - &original, - &edits, - ) - .is_some() + if crate::scene::view::dispatch::refit_arc_grips(current, &original, &edits) + .is_some() { rebuilt_arcs.insert(handle); } @@ -1768,10 +1718,7 @@ impl OpenCADStudio { .iter() .find(|(handle, _)| *handle == target.handle) .map(|(_, entity)| entity)?; - let current = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; added_arc_bulge(original, current, target.grip_id) .map(|bulge| (target.handle, target.grip_id, bulge)) }) @@ -1792,11 +1739,14 @@ impl OpenCADStudio { } // Re-solve constrained neighbors on each drag frame and include // their original state in the gesture's undo record. - let solved_by_constraints = self.tabs[i].scene.solve_sketch_constraints_preview(&edited_handles); + let solved_by_constraints = self.tabs[i] + .scene + .solve_parametric_constraints_preview(&edited_handles); for (handle, _) in &solved_by_constraints { let handle = *handle; if !self.grip_preview_handles.contains(&handle) { - if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() { + if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() + { self.grip_originals.push((handle, original)); } self.grip_preview_handles.push(handle); @@ -1848,8 +1798,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_preview_wires(Vec::new()); } else { // Current deformed geometry. - let mut preview = - self.tabs[i].scene.grip_wire_models_for(&edited_handles); + let mut preview = self.tabs[i].scene.grip_wire_models_for(&edited_handles); // Also show the drag-start geometry as a faint ghost. // @@ -1896,9 +1845,10 @@ impl OpenCADStudio { .collect(); if let Some(active) = self.tabs[i].active_grip.as_mut() { for target in &mut active.targets { - if let Some((_, world)) = refreshed_targets.iter().find(|((handle, id), _)| { - *handle == target.handle && *id == target.grip_id - }) { + if let Some((_, world)) = refreshed_targets + .iter() + .find(|((handle, id), _)| *handle == target.handle && *id == target.grip_id) + { target.last_world = *world; } } @@ -1942,15 +1892,14 @@ impl OpenCADStudio { // O(N) per frame and stalls the cursor on large drawings, // so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only // once the cursor has been still for `HOVER_DWELL_MS`. - let deferred_command_hover = self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + let deferred_command_hover = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + command.needs_entity_pick() && command.entity_pick_deferred_hover() + }); if !dragging && (self.tabs[i].active_cmd.is_none() || deferred_command_hover) { // On dense drawings, clearing a rollover immediately schedules a // second full scene frame just as motion resumes. Keep the previous // highlight until the next settled pick replaces it. - if self.tabs[i].scene.last_tess_wires.get() - < crate::app::HOVER_DWELL_DENSE_WIRES - { + if self.tabs[i].scene.last_tess_wires.get() < crate::app::HOVER_DWELL_DENSE_WIRES { self.tabs[i].scene.set_hover_highlight(None); } self.hover_dwell = Some(crate::app::HoverDwell { @@ -2065,14 +2014,7 @@ impl OpenCADStudio { None } else { self.last_point.and_then(|base| { - self.active_construction_ray( - i, - snap_cursor, - base, - view_rot, - eye, - bounds, - ) + self.active_construction_ray(i, snap_cursor, base, view_rot, eye, bounds) }) }; @@ -2190,10 +2132,7 @@ impl OpenCADStudio { // the point onto the line through last_point parallel to the // acquired reference, and drive the alignment guide off it. // (#277) - if axis_lock.is_none() - && self.tabs[i].snap_result.is_none() - && otrack_hit.is_none() - { + if axis_lock.is_none() && self.tabs[i].snap_result.is_none() && otrack_hit.is_none() { if let Some(par) = self.snapper.parallel_snap( cursor_world.as_vec3(), self.last_point.map(|p| p.as_vec3()), @@ -2211,54 +2150,53 @@ impl OpenCADStudio { } let effective = { - let mut pt: glam::DVec3 = if let (Some(dir), Some(base)) = - (axis_lock, self.last_point) - { - let point = self.tabs[i] - .snap_result - .map(|snap| snap.world) - .unwrap_or(cursor_world); - axis_lock_apply(point, base, dir) - } else if let Some(h) = otrack_hit { - h.aligned - } else { - // Snap runs in model space (viewport camera or the - // model/paper view), so the result is already model. - let mut pt = self.tabs[i] - .snap_result - .map(|s| s.world) - .unwrap_or(cursor_world); - let osnap_locked = self.tabs[i] - .snap_result - .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); - if !osnap_locked && !is_window_corner && !uses_command_cursor_plane { - if let Some(base) = self.last_point { - let ucs_xf = self.tabs[i].ucs_xform(); - if self.ortho_mode { - pt = drafting_constrain( - pt, - base, - &ucs_xf, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - ); - } else if self.polar_mode { - pt = polar_constrain_near( - pt, - base, - self.polar_increment_deg, - view_rot, - eye, - bounds, - self.snapper.osnap_radius_px, - &ucs_xf, - ); + let mut pt: glam::DVec3 = + if let (Some(dir), Some(base)) = (axis_lock, self.last_point) { + let point = self.tabs[i] + .snap_result + .map(|snap| snap.world) + .unwrap_or(cursor_world); + axis_lock_apply(point, base, dir) + } else if let Some(h) = otrack_hit { + h.aligned + } else { + // Snap runs in model space (viewport camera or the + // model/paper view), so the result is already model. + let mut pt = self.tabs[i] + .snap_result + .map(|s| s.world) + .unwrap_or(cursor_world); + let osnap_locked = self.tabs[i] + .snap_result + .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); + if !osnap_locked && !is_window_corner && !uses_command_cursor_plane { + if let Some(base) = self.last_point { + let ucs_xf = self.tabs[i].ucs_xform(); + if self.ortho_mode { + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + } else if self.polar_mode { + pt = polar_constrain_near( + pt, + base, + self.polar_increment_deg, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + &ucs_xf, + ); + } } } - } - pt - }; + pt + }; // Clamp to world XY only when no UCS is active; with a // UCS the point already lies on the UCS XY plane. if self.tabs[i].active_cmd.is_some() @@ -2269,25 +2207,31 @@ impl OpenCADStudio { } pt }; - let effective = if needs_entity && self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_accepts_points()) + let effective = if needs_entity + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) { cursor_world - } else { self.tabs[i] - .active_cmd - .as_ref() - .and_then(|command| command.cursor_axis()) - .and_then(|(origin, direction)| { - cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) - }) - .unwrap_or(effective) }; + } else { + self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.cursor_axis()) + .and_then(|(origin, direction)| { + cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) + }) + .unwrap_or(effective) + }; // Dynamic-input locked fields constrain the preview point // (#356): a typed angle pins the direction, a typed // distance pins the radius — the same resolution the Enter // commit uses, so preview and commit agree. The lock wins // over osnap/ortho/polar. let effective = { - let locked = !needs_entity && self.tabs[i].active_cmd.is_some() + let locked = !needs_entity + && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()); if locked { self.tabs[i].last_cursor_world = effective; @@ -2485,10 +2429,17 @@ impl OpenCADStudio { self.tabs[i].scene.set_hover_highlight(None); } let hover_handle = hovered.unwrap_or(acadrust::Handle::NULL); - let wants_entity = self.tabs[i].active_cmd.as_ref() + let wants_entity = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.wants_hover_entity(hover_handle)); if wants_entity { - if let Some(entity) = self.tabs[i].scene.document.get_entity(hover_handle).cloned() { + if let Some(entity) = self.tabs[i] + .scene + .document + .get_entity(hover_handle) + .cloned() + { if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.inject_hover_entity(hover_handle, entity); } @@ -2636,8 +2587,7 @@ impl OpenCADStudio { self.sync_dyn_fields(); let move_ms = move_started.elapsed().as_secs_f64() * 1000.0; if perf_move - && (self.tabs[i].active_cmd.is_some() - || self.tabs[i].scene.active_viewport.is_some()) + && (self.tabs[i].active_cmd.is_some() || self.tabs[i].scene.active_viewport.is_some()) && move_ms >= 16.7 { let mode = if self.tabs[i].active_cmd.is_some() { @@ -2645,10 +2595,7 @@ impl OpenCADStudio { } else { "MSPACE" }; - crate::perf_record!( - "[perf] pointer-move mode={mode:<7} {:>7.1}ms", - move_ms, - ); + crate::perf_record!("[perf] pointer-move mode={mode:<7} {:>7.1}ms", move_ms,); } Task::none() } @@ -2656,7 +2603,9 @@ impl OpenCADStudio { pub(crate) fn on_viewport_exit(&mut self) -> Task { let i = self.active_tab; self.hover_dwell = None; - if self.tabs[i].active_cmd.as_ref() + if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_deferred_hover()) { self.tabs[i].scene.clear_preview_wire(); @@ -2805,9 +2754,17 @@ impl OpenCADStudio { // No rubber-band origin (perp/extension feet don't apply to a free drag). self.snapper.from_point = None; let (go, gr) = self.drafting_grid_basis(i); - let snap_hit = self - .snapper - .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr, None); + let snap_hit = self.snapper.snap( + raw, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + None, + ); let world = snap_hit.map(|s| s.world).unwrap_or(raw); self.tabs[i].snap_result = snap_hit; if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2868,9 +2825,8 @@ impl OpenCADStudio { /// its transient origin and axes are baked into the block definition, then /// the editor returns to canonical local coordinates. pub(in crate::app) fn commit_active_ucs_change(&mut self, i: usize, label: &'static str) { - if let Some((session_index, block_record)) = self.tabs[i] - .active_block_edit - .and_then(|index| { + if let Some((session_index, block_record)) = + self.tabs[i].active_block_edit.and_then(|index| { self.tabs[i] .block_edits .get(index) @@ -2917,9 +2873,8 @@ impl OpenCADStudio { } else { None }; - let same_basis = |a: Option<&acadrust::tables::Ucs>, - b: Option<&acadrust::tables::Ucs>| { - match (a, b) { + let same_basis = + |a: Option<&acadrust::tables::Ucs>, b: Option<&acadrust::tables::Ucs>| match (a, b) { (None, None) => true, (Some(a), Some(b)) => { a.origin == b.origin @@ -2929,8 +2884,7 @@ impl OpenCADStudio { && a.name.eq_ignore_ascii_case(&b.name) } _ => false, - } - }; + }; if !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()) { self.push_undo_snapshot(i, label); self.tabs[i].persist_active_ucs(); @@ -3065,10 +3019,7 @@ impl OpenCADStudio { // Interactive navigation tools reuse the middle-button movement path, // so no selection/pick logic runs while the left button drives them. - if self.tabs[i].orbit_mode - || self.tabs[i].pan_mode - || self.tabs[i].zoom_dynamic_mode - { + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode || self.tabs[i].zoom_dynamic_mode { self.clear_navigation_hover(i); self.tabs[i].scene.remember_current_view(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); @@ -3247,13 +3198,8 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = Some(self.grip_edit_for_hit( - i, - handle, - grip_id, - is_translate, - world, - )); + self.tabs[i].active_grip = + Some(self.grip_edit_for_hit(i, handle, grip_id, is_translate, world)); self.grip_hover = None; self.grip_popup = None; self.tabs[i] @@ -3295,8 +3241,13 @@ impl OpenCADStudio { .and_then(scene::pe_url_of) .and_then(crate::sys::web_hyperlink); if let Some(url) = url { - self.tabs[i].scene.selection.borrow_mut().clear_left_selection_gesture(); - self.command_line.push_info(&format!("{}: {url}", crate::t!("Hyperlink"))); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + self.command_line + .push_info(&format!("{}: {url}", crate::t!("Hyperlink"))); return crate::sys::open_url(&url, self.main_window); } } @@ -3321,10 +3272,7 @@ impl OpenCADStudio { // Navigation mode: end this drag but keep the tool armed for the next // left drag (exit is Esc / another command). Mirror of the press. - if self.tabs[i].orbit_mode - || self.tabs[i].pan_mode - || self.tabs[i].zoom_dynamic_mode - { + if self.tabs[i].orbit_mode || self.tabs[i].pan_mode || self.tabs[i].zoom_dynamic_mode { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.middle_down = false; sel.middle_last_pos = None; @@ -3381,8 +3329,11 @@ impl OpenCADStudio { } if matches!( grip.mode, - GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength - | GripEditMode::RectangleWidth | GripEditMode::RectangleHeight + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight | GripEditMode::MoveParallel ) { self.grip_pending = None; @@ -3394,17 +3345,23 @@ impl OpenCADStudio { .iter() .find(|(handle, _)| *handle == target.handle) .map(|(_, entity)| entity)?; - let current = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; is_added_polyline_vertex(original, current, target.grip_id) .then_some(target.grip_id) }); // Keep originals available until every history shape has a valid // final display. A rejected rebuild cancels the entire gesture. - let history_handles: Vec<_> = self.grip_preview_handles.iter().copied() - .filter(|handle| self.tabs[i].scene.document.solid_history_operation(*handle).is_some()) + let history_handles: Vec<_> = self + .grip_preview_handles + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .solid_history_operation(*handle) + .is_some() + }) .collect(); for handle in history_handles { if !self.tabs[i].scene.finalize_solid_history(handle) { @@ -3493,12 +3450,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.is_selection_gathering()) .unwrap_or(false); - let selection_box_active = self.tabs[i] - .scene - .selection - .borrow() - .box_anchor - .is_some(); + let selection_box_active = self.tabs[i].scene.selection.borrow().box_anchor.is_some(); let selection_pick_add = self.pick_add || self.tabs[i] .active_cmd @@ -3742,7 +3694,8 @@ impl OpenCADStudio { // A click while dynamic-input fields hold typed values // commits the CONSTRAINED point — the same resolution // the preview shows and Enter would commit (#356). - if !needs_entity_click && self.tabs[i].active_cmd.is_some() + if !needs_entity_click + && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()) { self.tabs[i].last_cursor_world = pt; @@ -3750,8 +3703,11 @@ impl OpenCADStudio { pt = r; } } - if needs_entity_click && self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_accepts_points()) + if needs_entity_click + && self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) { raw } else { @@ -3890,45 +3846,59 @@ impl OpenCADStudio { self.tabs[i] .scene .solid_click_point_for(p, view_rot2, eye2, bounds, handle) - .or_else(|| self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.entity_pick_deferred_hover()) - .then(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)).flatten()) + .or_else(|| { + self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + .then(|| { + self.profile_pick_point(i, handle, &edit_cam, p, bounds) + }) + .flatten() + }) .unwrap_or_else(|| { - let bounded_pick = self.tabs[i].active_cmd.as_ref() + let bounded_pick = self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_deferred_hover()); - if bounded_pick && matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)), - ) { + if bounded_pick + && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) + { // The aperture caught an edge outside its face. // Do not reinterpret the working-plane point as // a hit on another cap of the same body. glam::DVec3::NAN - } else { pick_wcs } + } else { + pick_wcs + } }) } else { pick_wcs }; - let entity_pick_direction = if uses_surface_point && entity_pick_point.is_finite() { - if matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)) - ) { - self.tabs[i] - .scene - .solid_planar_face_normal_at(handle, entity_pick_point) + let entity_pick_direction = + if uses_surface_point && entity_pick_point.is_finite() { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + self.tabs[i] + .scene + .solid_planar_face_normal_at(handle, entity_pick_point) + } else { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::entities::curve::entity_curve) + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array) + } } else { - self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(crate::entities::curve::entity_curve) - .and_then(|curve| curve.plane.normal()) - .map(glam::DVec3::from_array) - } - } else { - None - }; + None + }; // Some commands (e.g. SS_CATCHMENT) need the entity // body before `on_entity_pick` can advance. let inject_first = self.tabs[i] @@ -3972,11 +3942,11 @@ impl OpenCADStudio { if let Some(model) = self.tabs[i].scene.hatches.get(&handle).cloned() { let entity = self.tabs[i].scene.document.get_entity(handle); let annotative = entity.is_some_and(|entity| { - crate::scene::annotative::is_annotative( - &self.tabs[i].scene.document, - entity, - ) - }); + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); let (scale, angle) = match entity { Some(acadrust::EntityType::Hatch(hatch)) => ( hatch.pattern_scale as f32, @@ -4032,11 +4002,16 @@ impl OpenCADStudio { } } result - } else if self.tabs[i].active_cmd.as_ref() + } else if self.tabs[i] + .active_cmd + .as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { self.refresh_command_point_pick_context(i); - self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) + self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd .as_ref() @@ -4068,7 +4043,8 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_tangent_point(obj, pick_wcs)) } else { - self.command_line.push_info(crate::t!("Select a tangent object.").as_ref()); + self.command_line + .push_info(crate::t!("Select a tangent object.").as_ref()); None } } else if !self.command_point_allowed(i, world_pt) { @@ -4196,8 +4172,7 @@ impl OpenCADStudio { }) .collect(); if points.last().is_none_or(|last| { - (last.x - p.x).abs() > f32::EPSILON - || (last.y - p.y).abs() > f32::EPSILON + (last.x - p.x).abs() > f32::EPSILON || (last.y - p.y).abs() > f32::EPSILON }) { points.push(p); } @@ -4307,8 +4282,7 @@ impl OpenCADStudio { eye, bounds, ); - let cand_ms = t_sel - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene @@ -4370,8 +4344,7 @@ impl OpenCADStudio { // selection, Shift+box removes the boxed // entities. Esc / empty-space click still clears. // PICKADD 0 (#226): a plain box REPLACES. - let hit_ms = t_hit - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_apply = crate::perf::enabled().then(Instant::now); if self.shift_down || self.select_remove_mode { self.tabs[i].scene.deselect_entities(&handles); @@ -4382,8 +4355,7 @@ impl OpenCADStudio { self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } - let apply_ms = t_apply - .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let apply_ms = t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); if crate::perf::enabled() { @@ -4642,10 +4614,7 @@ was_selected={}", ); let command_result = self.tabs[i].active_cmd.as_mut().and_then(|command| { command.set_shift(self.shift_down); - command.on_drag_selection( - &command_fence, - Some((command_min, command_max)), - ) + command.on_drag_selection(&command_fence, Some((command_min, command_max))) }); if let Some(result) = command_result { let mut selection = self.tabs[i].scene.selection.borrow_mut(); @@ -4694,8 +4663,7 @@ was_selected={}", eye, bounds, ); - let cand_ms = - t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene @@ -4762,8 +4730,7 @@ was_selected={}", let t_filter = crate::perf::enabled().then(Instant::now); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - let filter_ms = - t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let filter_ms = t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_apply = crate::perf::enabled().then(Instant::now); // Accumulate (issue #83): a plain box adds to the // current selection, Shift+box removes the boxed @@ -4779,8 +4746,7 @@ was_selected={}", self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } - let apply_ms = - t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let apply_ms = t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); if crate::perf::enabled() { @@ -4902,9 +4868,12 @@ properties={:.1}ms picked={}", // Locked layer: double-click must not open any editor // (text / attribute / in-place block edit). if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - self.command_line.push_info(crate::tf!( + self.command_line.push_info( + crate::tf!( "Object is on locked layer \"{layer}\" — unlock the layer to edit it." - ).as_ref()); + ) + .as_ref(), + ); return Task::none(); } // Table double-click: shared TABLEDIT editor setup. A @@ -5055,10 +5024,7 @@ properties={:.1}ms picked={}", if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if !Scene::is_sheet_viewport( - &self.tabs[i].scene.document, - vp, - ) { + if !Scene::is_sheet_viewport(&self.tabs[i].scene.document, vp) { Some(h) } else { None @@ -5125,8 +5091,11 @@ properties={:.1}ms picked={}", /// ends. Repeated wheel events replace this timestamp, so the pick runs only /// after the final zoom step settles. pub(in crate::app) fn arm_hover_after_navigation(&mut self, i: usize) { - if i >= self.tabs.len() || self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| !command.entity_pick_deferred_hover()) + if i >= self.tabs.len() + || self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| !command.entity_pick_deferred_hover()) { return; } @@ -5180,7 +5149,8 @@ properties={:.1}ms picked={}", self.tabs[i] .scene .record_nav_perf(crate::scene::NavPerfOp::Zoom, now); - self.command_line.push_output(crate::t!("Zoom Extents").as_ref()); + self.command_line + .push_output(crate::t!("Zoom Extents").as_ref()); } Task::none() } @@ -5285,8 +5255,7 @@ properties={:.1}ms picked={}", ) -> Task { let i = self.active_tab; if self.tabs[i].scene.active_viewport != expected_viewport - || (expected_viewport.is_none() - && self.tabs[i].scene.current_layout != "Model") + || (expected_viewport.is_none() && self.tabs[i].scene.current_layout != "Model") { return Task::none(); } @@ -5427,8 +5396,9 @@ properties={:.1}ms picked={}", command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); } - let deferred_command = self.tabs[i].active_cmd.as_ref() - .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + let deferred_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + command.needs_entity_pick() && command.entity_pick_deferred_hover() + }); let navigating = self.tabs[i].scene.selection.borrow().middle_down; if (self.tabs[i].active_cmd.is_some() && !deferred_command) || navigating { self.clear_navigation_hover(i); @@ -5521,27 +5491,40 @@ properties={:.1}ms picked={}", } if hovered.is_none() { let started = Instant::now(); - hovered = if deferred_command { self.tabs[i].scene.solid_click_hit( - p, view_rot, eye, bounds, candidate_handles.as_ref(), - ) } else { self.tabs[i].scene.solid_edge_hover_hit( - p, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ) }; + hovered = if deferred_command { + self.tabs[i].scene.solid_click_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + } else { + self.tabs[i].scene.solid_edge_hover_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + }; solid_ms = started.elapsed().as_secs_f64() * 1000.0; } if deferred_command { let surface_point = hovered.and_then(|handle| { - self.tabs[i].scene.solid_click_point_for(p, view_rot, eye, bounds, handle) + self.tabs[i] + .scene + .solid_click_point_for(p, view_rot, eye, bounds, handle) .or_else(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)) }); let point = surface_point.unwrap_or(hover_world); - let is_solid = hovered.is_some_and(|handle| matches!( - self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)), - )); + let is_solid = hovered.is_some_and(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) + }); // An aperture may catch an edge beside, rather than on, a face. // Never send its XY-plane fallback as a 3D face pick. if is_solid && surface_point.is_none() { @@ -5550,17 +5533,26 @@ properties={:.1}ms picked={}", self.hover_dwell = None; return Task::none(); } - let handles = self.tabs[i].scene.document.entities().filter_map(|entity| { - matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) - }).collect::>(); + let handles = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)) + .then_some(entity.common().handle) + }) + .collect::>(); self.tabs[i].scene.restore_solid_models(&handles); let show = (self.model_space.selection_preview & 2) != 0; let previews = if show { let tab = &mut self.tabs[i]; - tab.active_cmd.as_mut().map(|command| { - command.on_deferred_entity_hover(&tab.scene, hovered, point) - }).unwrap_or_default() - } else { Vec::new() }; + tab.active_cmd + .as_mut() + .map(|command| command.on_deferred_entity_hover(&tab.scene, hovered, point)) + .unwrap_or_default() + } else { + Vec::new() + }; self.tabs[i].scene.set_hover_highlight(None); self.tabs[i].scene.set_preview_wires(previews); self.hover_dwell = None; @@ -5705,10 +5697,7 @@ properties={:.1}ms picked={}", /// MVIEW's "Insert view > New" flow deliberately visits Model space to /// define a view and then returns to its paper layout. This is the only /// layout transition allowed to preserve an active command. - pub(crate) fn on_layout_switch_preserving_command( - &mut self, - name: String, - ) -> Task { + pub(crate) fn on_layout_switch_preserving_command(&mut self, name: String) -> Task { self.on_layout_switch_inner(name, true) } @@ -5859,11 +5848,7 @@ properties={:.1}ms picked={}", .header .paper_space_linetype_scaling, ) | (i16::from( - self.tabs[i] - .scene - .document - .header - .paper_space_limit_check, + self.tabs[i].scene.document.header.paper_space_limit_check, ) << 1); let plot_style = self .active_plot_style @@ -5901,9 +5886,10 @@ properties={:.1}ms picked={}", self.tabs[i].scene.ensure_sheet_viewport(&new_name); let switch_task = self.on_layout_switch(new_name.clone()); self.tabs[i].scene.fit_all(); - self.command_line.push_output(crate::tf!( - "Layout \"{new_name}\" created — use MVIEW to add a viewport" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport") + .as_ref(), + ); self.tabs[i].dirty = true; return Task::batch([cancel_task, switch_task]); } @@ -5932,14 +5918,13 @@ properties={:.1}ms picked={}", .get(&new_name) .is_some() { - self.command_line - .push_error(crate::tf!("\"{}\" name already in use", new_name).as_ref()); + self.command_line.push_error( + crate::tf!("\"{}\" name already in use", new_name).as_ref(), + ); } else { self.push_undo_snapshot(i, "BLOCK RENAME"); if self.tabs[i].scene.rename_block(&orig, &new_name) { - if let Some(session) = - self.tabs[i].active_block_edit_session_mut() - { + if let Some(session) = self.tabs[i].active_block_edit_session_mut() { session.block_name = new_name.clone(); } for session in &mut self.tabs[i].block_edits { @@ -5948,11 +5933,13 @@ properties={:.1}ms picked={}", } } self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Block \"{orig}\" → \"{new_name}\"").as_ref()); + self.command_line.push_output( + crate::tf!("Block \"{orig}\" → \"{new_name}\"").as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("Could not rename block \"{orig}\"").as_ref()); + self.command_line.push_error( + crate::tf!("Could not rename block \"{orig}\"").as_ref(), + ); } } return Task::none(); @@ -5988,8 +5975,8 @@ mod selection_preview_tests { #[test] fn grip_moves_keep_perpendicular_constraints_live_and_undoable() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; use acadrust::{entities::Line, types::Vector3, EntityType}; - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); @@ -6000,37 +5987,58 @@ mod selection_preview_tests { app.ortho_mode = false; app.polar_mode = false; app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); - let a = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(-10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), - ))); - let b = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( - Vector3::new(15.0, -10.0, 0.0), Vector3::new(15.0, 10.0, 0.0), - ))); - let _ = app.apply_cmd_result(crate::command::CmdResult::AddSketchConstraint { + let a = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(-10.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let b = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(15.0, -10.0, 0.0), + Vector3::new(15.0, 10.0, 0.0), + ))); + let _ = app.apply_cmd_result(crate::command::CmdResult::AddParametricConstraint { kind: ConstraintKind::Perpendicular, - refs: vec![SketchRef::whole(a), SketchRef::whole(b)], + refs: vec![ParametricRef::whole(a), ParametricRef::whole(b)], driving_param: None, label: "Perpendicular constraint", }); - let geometry = |app: &OpenCADStudio| [a, b].map(|handle| { - match app.tabs[i].scene.document.get_entity(handle).unwrap() { - EntityType::Line(line) => [line.start, line.end], - _ => panic!("expected line"), - } - }); + let geometry = |app: &OpenCADStudio| { + [a, b].map( + |handle| match app.tabs[i].scene.document.get_entity(handle).unwrap() { + EntityType::Line(line) => [line.start, line.end], + _ => panic!("expected line"), + }, + ) + }; let before = geometry(&app); - app.tabs[i].active_grip = Some(GripEdit::single(a, 1, false, glam::DVec3::new(10.0, 0.0, 0.0))); + app.tabs[i].active_grip = Some(GripEdit::single( + a, + 1, + false, + glam::DVec3::new(10.0, 0.0, 0.0), + )); for cursor in [Point::new(520.0, 220.0), Point::new(490.0, 180.0)] { let _ = app.on_viewport_move(cursor); let current = geometry(&app); for point in current.iter().flatten() { assert_eq!(point.z, 0.0, "grip left the drawing plane: {point:?}"); } - let direction = |ends: [Vector3; 2]| glam::DVec3::new( - ends[1].x - ends[0].x, ends[1].y - ends[0].y, ends[1].z - ends[0].z, - ).normalize(); + let direction = |ends: [Vector3; 2]| { + glam::DVec3::new( + ends[1].x - ends[0].x, + ends[1].y - ends[0].y, + ends[1].z - ends[0].z, + ) + .normalize() + }; assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); - assert_ne!(current[1], before[1], "constrained neighbor must follow each grip frame"); + assert_ne!( + current[1], before[1], + "constrained neighbor must follow each grip frame" + ); } let _ = app.on_viewport_left_release(); assert!(app.tabs[i].active_grip.is_none()); @@ -6044,10 +6052,10 @@ mod selection_preview_tests { #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { - use acadrust::EntityType; use acadrust::entities::Line; use acadrust::types::Vector3; use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + use acadrust::EntityType; use glam::DVec3; for mode in ["link", "plain", "pan", "orbit", "zoom", "grip", "command"] { @@ -6057,10 +6065,8 @@ mod selection_preview_tests { app.show_ucs_icon = false; // Keep the browser-opening window task deferred during this test. app.main_window = Some(iced::window::Id::unique()); - let mut line = Line::from_points( - Vector3::new(-10.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ); + let mut line = + Line::from_points(Vector3::new(-10.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); let mut record = ExtendedDataRecord::new("PE_URL"); record.add_value(XDataValue::String("https://example.com/linked".into())); line.common.extended_data.add_record(record); @@ -6073,12 +6079,20 @@ mod selection_preview_tests { "pan" => app.tabs[i].pan_mode = true, "orbit" => app.tabs[i].orbit_mode = true, "zoom" => app.tabs[i].zoom_dynamic_mode = true, - "grip" => app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, DVec3::ZERO)), - "command" => { let _ = app.run_command_line("LINE"); } + "grip" => { + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, DVec3::ZERO)) + } + "command" => { + let _ = app.run_command_line("LINE"); + } _ => {} } let task = app.on_viewport_left_press(); - let opened = app.command_line.history.iter().any(|entry| entry.text.contains("https://example.com/linked")); + let opened = app + .command_line + .history + .iter() + .any(|entry| entry.text.contains("https://example.com/linked")); assert_eq!(opened, mode == "link", "{mode}"); if mode == "link" { assert!(task.units() > 0); @@ -6124,14 +6138,8 @@ mod selection_preview_tests { rollover_hits(1), "bit 1 set: the line under the cursor must be highlighted", ); - assert!( - rollover_hits(3), - "both bits set: still highlighted", - ); - assert!( - !rollover_hits(0), - "preview off: nothing may be highlighted", - ); + assert!(rollover_hits(3), "both bits set: still highlighted",); + assert!(!rollover_hits(0), "preview off: nothing may be highlighted",); assert!( !rollover_hits(2), "only the in-command bit: the idle rollover stays off", diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 285905fcb..bd48e6260 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -5,6 +5,7 @@ use super::{ArrowKey, Message, OpenCADStudio}; use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte}; use crate::scene::view::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX}; +use crate::t; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ @@ -14,7 +15,6 @@ use iced::widget::{ use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme}; use iced_aw::ContextMenu; -use crate::t; mod controls; mod modal; @@ -132,11 +132,9 @@ fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Opt "ArrowRight" => "RIGHT".to_string(), "PageUp" => "PAGEUP".to_string(), "PageDown" => "PAGEDOWN".to_string(), - "Enter" | "Space" | "Escape" | "Delete" | "Backspace" | "Tab" | "Home" - | "End" | "Insert" => name.to_uppercase(), - _ if name.starts_with('F') - && name[1..].chars().all(|ch| ch.is_ascii_digit()) => - { + "Enter" | "Space" | "Escape" | "Delete" | "Backspace" | "Tab" | "Home" | "End" + | "Insert" => name.to_uppercase(), + _ if name.starts_with('F') && name[1..].chars().all(|ch| ch.is_ascii_digit()) => { name } _ => return None, @@ -217,12 +215,7 @@ impl OpenCADStudio { let tab = &self.tabs[i]; let thumbnail_capture_clean = self.thumbnail_capture_clean; let theme_text = self.active_theme.palette().background.base.text; - let viewcube_text_color = [ - theme_text.r, - theme_text.g, - theme_text.b, - theme_text.a, - ]; + let viewcube_text_color = [theme_text.r, theme_text.g, theme_text.b, theme_text.a]; let is_paper = tab.scene.current_layout != "Model"; let committed_render_mode = if is_paper { tab.scene @@ -346,9 +339,8 @@ impl OpenCADStudio { Space::new().width(Fill).height(Fill) }) .into(); - let shaders = pane_grid::PaneGrid::new( - &scene.model_panes, - move |_pane, &idx, _maximized| { + let shaders = + pane_grid::PaneGrid::new(&scene.model_panes, move |_pane, &idx, _maximized| { pane_grid::Content::new( shader(ViewportPane::for_pane( scene, @@ -361,12 +353,11 @@ impl OpenCADStudio { .width(Fill) .height(Fill), ) - }, - ) - .width(Fill) - .height(Fill) - .min_size(scene.model_pane_min_px()) - .spacing(crate::scene::TILE_DIVIDER_PX); + }) + .width(Fill) + .height(Fill) + .min_size(scene.model_pane_min_px()) + .spacing(crate::scene::TILE_DIVIDER_PX); stack![size_probe, shaders].width(Fill).height(Fill).into() }; @@ -375,22 +366,23 @@ impl OpenCADStudio { // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. mark("model_input"); - let model_input_layer: Option> = if is_paper || tab.is_start || self.layout_settling { - None - } else { - let scene = &tab.scene; - Some( - pane_grid::PaneGrid::new(&scene.model_panes, |_pane, &idx, _maximized| { - pane_grid::Content::new(pane_mouse_area(idx)) - }) - .width(Fill) - .height(Fill) - .min_size(scene.model_pane_min_px()) - .spacing(crate::scene::TILE_DIVIDER_PX) - .on_resize(6.0, Message::PaneResized) - .into(), - ) - }; + let model_input_layer: Option> = + if is_paper || tab.is_start || self.layout_settling { + None + } else { + let scene = &tab.scene; + Some( + pane_grid::PaneGrid::new(&scene.model_panes, |_pane, &idx, _maximized| { + pane_grid::Content::new(pane_mouse_area(idx)) + }) + .width(Fill) + .height(Fill) + .min_size(scene.model_pane_min_px()) + .spacing(crate::scene::TILE_DIVIDER_PX) + .on_resize(6.0, Message::PaneResized) + .into(), + ) + }; mark("grid"); let grid_overlay = if self.layout_settling || tab.is_start { @@ -423,8 +415,7 @@ impl OpenCADStudio { let (origin, mut axes): (glam::DVec3, _) = if is_paper { match tab.ucs_from_viewport(handle) { Some(u) => { - let (o, ux, uy, uz) = - super::helpers::UcsXform::from_ucs(&u).axes(); + let (o, ux, uy, uz) = super::helpers::UcsXform::from_ucs(&u).axes(); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) } None => ( @@ -501,103 +492,103 @@ bg={bg_ms:.1}ms n={view_count}" .as_ref() .is_some_and(|cmd| cmd.name() == "STRETCH"); - let grips: Vec = - if show_grips && !tab.selected_grips.is_empty() { - let (vw, vh) = sel_ref.vp_size; - // Overlays project through the active tile's camera, so - // they must use the active tile's screen rectangle (with - // its canvas offset) — not the whole canvas — or they - // land in the wrong place in a tiled layout. - // Inside a floating viewport the pane is the viewport's own - // rect + camera; otherwise the active model tile. - let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); - let bounds = match &edit_frame { - Some((_, full)) => *full, - None => tab.scene.active_model_tile_bounds(vw, vh), - }; - let sel_h = tab.selected_handle; - // Mark the Properties panel's current point grip hot. - let current_vertex_grip: Option = tab - .properties - .prop_vertex_indicator_active - .then(|| sel_h) - .flatten() - .and_then(|h| { - let indexed = match tab.scene.document.get_entity(h) { - Some(acadrust::EntityType::LwPolyline(_)) - | Some(acadrust::EntityType::Polyline2D(_)) - | Some(acadrust::EntityType::Polyline3D(_)) - | Some(acadrust::EntityType::Spline(_)) - | Some(acadrust::EntityType::Face3D(_)) - | Some(acadrust::EntityType::PolygonMesh(_)) => true, - _ => false, - }; - indexed.then_some(tab.properties.prop_vertex) - }); - // In-viewport grips are model-space; project them with the - // viewport camera so they sit on the wire the GPU draws. - // Paper entities use the 2-D paper transform; the model tab - // uses the model camera. - let screen_grips = if let Some((cam, _)) = &edit_frame { - grips_to_screen_rte( - &tab.selected_grips, - cam.view_proj_rte(bounds), - cam.eye(), - bounds, - ) - } else if is_paper { - let cam = tab.scene.camera.borrow(); - let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; - let half_h = cam.ortho_size(); - let half_w = half_h * aspect; - let tx = cam.target.x as f32; - let ty = cam.target.y as f32; - drop(cam); - grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds) - } else { - let cam = tab.scene.camera.borrow(); - grips_to_screen(&tab.selected_grips, &cam, bounds) - }; - screen_grips - .into_iter() - .enumerate() - .filter(|(_, (_, screen, _, _, _))| { - screen.x.is_finite() - && screen.y.is_finite() - && screen.x >= -bounds.width - && screen.x <= bounds.width * 2.0 - && screen.y >= -bounds.height - && screen.y <= bounds.height * 2.0 - }) - .map(|(index, (grip_id, screen, _is_midpoint, shape, dir))| { - let owner = tab.selected_grip_handles.get(index).copied(); - let is_hot = owner.is_some_and(|handle| { - tab.hot_grips.contains(&(handle, grip_id)) - || tab.active_grip.as_ref().is_some_and(|edit| { - edit.targets.iter().any(|target| { - target.handle == handle && target.grip_id == grip_id - }) - }) - || (Some(handle) == sel_h - && Some(grip_id) == current_vertex_grip) - }); - let is_hovered = owner.is_some_and(|handle| { - self.grip_hover.as_ref().is_some_and(|hover| { - hover.handle == handle && hover.grip_id == grip_id - }) - }); - crate::ui::overlay::GripMarker { - pos: screen, - shape, - is_hot, - is_hovered, - dir, - } - }) - .collect() - } else { - vec![] + let grips: Vec = if show_grips + && !tab.selected_grips.is_empty() + { + let (vw, vh) = sel_ref.vp_size; + // Overlays project through the active tile's camera, so + // they must use the active tile's screen rectangle (with + // its canvas offset) — not the whole canvas — or they + // land in the wrong place in a tiled layout. + // Inside a floating viewport the pane is the viewport's own + // rect + camera; otherwise the active model tile. + let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => tab.scene.active_model_tile_bounds(vw, vh), }; + let sel_h = tab.selected_handle; + // Mark the Properties panel's current point grip hot. + let current_vertex_grip: Option = tab + .properties + .prop_vertex_indicator_active + .then(|| sel_h) + .flatten() + .and_then(|h| { + let indexed = match tab.scene.document.get_entity(h) { + Some(acadrust::EntityType::LwPolyline(_)) + | Some(acadrust::EntityType::Polyline2D(_)) + | Some(acadrust::EntityType::Polyline3D(_)) + | Some(acadrust::EntityType::Spline(_)) + | Some(acadrust::EntityType::Face3D(_)) + | Some(acadrust::EntityType::PolygonMesh(_)) => true, + _ => false, + }; + indexed.then_some(tab.properties.prop_vertex) + }); + // In-viewport grips are model-space; project them with the + // viewport camera so they sit on the wire the GPU draws. + // Paper entities use the 2-D paper transform; the model tab + // uses the model camera. + let screen_grips = if let Some((cam, _)) = &edit_frame { + grips_to_screen_rte( + &tab.selected_grips, + cam.view_proj_rte(bounds), + cam.eye(), + bounds, + ) + } else if is_paper { + let cam = tab.scene.camera.borrow(); + let aspect = if vh > 0.0 { vw / vh } else { 1.0 }; + let half_h = cam.ortho_size(); + let half_w = half_h * aspect; + let tx = cam.target.x as f32; + let ty = cam.target.y as f32; + drop(cam); + grips_to_screen_paper(&tab.selected_grips, tx, ty, half_w, half_h, bounds) + } else { + let cam = tab.scene.camera.borrow(); + grips_to_screen(&tab.selected_grips, &cam, bounds) + }; + screen_grips + .into_iter() + .enumerate() + .filter(|(_, (_, screen, _, _, _))| { + screen.x.is_finite() + && screen.y.is_finite() + && screen.x >= -bounds.width + && screen.x <= bounds.width * 2.0 + && screen.y >= -bounds.height + && screen.y <= bounds.height * 2.0 + }) + .map(|(index, (grip_id, screen, _is_midpoint, shape, dir))| { + let owner = tab.selected_grip_handles.get(index).copied(); + let is_hot = owner.is_some_and(|handle| { + tab.hot_grips.contains(&(handle, grip_id)) + || tab.active_grip.as_ref().is_some_and(|edit| { + edit.targets.iter().any(|target| { + target.handle == handle && target.grip_id == grip_id + }) + }) + || (Some(handle) == sel_h && Some(grip_id) == current_vertex_grip) + }); + let is_hovered = owner.is_some_and(|handle| { + self.grip_hover.as_ref().is_some_and(|hover| { + hover.handle == handle && hover.grip_id == grip_id + }) + }); + crate::ui::overlay::GripMarker { + pos: screen, + shape, + is_hot, + is_hovered, + dir, + } + }) + .collect() + } else { + vec![] + }; let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline, @@ -612,15 +603,11 @@ bg={bg_ms:.1}ms n={view_count}" } let points: Vec<_> = grips .iter() - .filter(|grip| { - grip.shape == crate::scene::model::object::GripShape::Circle - }) + .filter(|grip| grip.shape == crate::scene::model::object::GripShape::Circle) .map(|grip| grip.pos) .collect(); - (points.len() >= 2).then_some(( - points, - spline.flags.closed || spline.flags.periodic, - )) + (points.len() >= 2) + .then_some((points, spline.flags.closed || spline.flags.periodic)) }); let grip_clip = if grips.is_empty() { None @@ -650,11 +637,14 @@ bg={bg_ms:.1}ms n={view_count}" vec![] } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { let (_, ux, uy, uz) = tab.ucs_xform().axes(); - let origin_screen = self.ucs_icon_at_origin.then(|| { - vp_cam - .project(tab.ucs_origin_world(), full) - .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) - }).flatten(); + let origin_screen = self + .ucs_icon_at_origin + .then(|| { + vp_cam + .project(tab.ucs_origin_world(), full) + .map(|p| iced::Point::new(full.x + p.x, full.y + p.y)) + }) + .flatten(); vec![crate::ui::overlay::UcsIconParams { view_proj: vp_cam.view_proj_rte(full), bounds: full, @@ -683,11 +673,18 @@ bg={bg_ms:.1}ms n={view_count}" width: (t.rect.width * vw).max(1.0), height: (t.rect.height * vh).max(1.0), }; - let cam = if i == active { live.clone() } else { t.camera.clone() }; - let origin_screen = self.ucs_icon_at_origin.then(|| { - cam.project(origin_w, b) - .map(|p| iced::Point::new(b.x + p.x, b.y + p.y)) - }).flatten(); + let cam = if i == active { + live.clone() + } else { + t.camera.clone() + }; + let origin_screen = self + .ucs_icon_at_origin + .then(|| { + cam.project(origin_w, b) + .map(|p| iced::Point::new(b.x + p.x, b.y + p.y)) + }) + .flatten(); crate::ui::overlay::UcsIconParams { view_proj: cam.view_proj_rte(b), bounds: b, @@ -711,10 +708,8 @@ bg={bg_ms:.1}ms n={view_count}" // viewport). Without the `ob.x/ob.y` offset it silently vanishes. // Shared OTRACK projection basis: the active pane's camera + rect // (canvas offset included), matching the grips / UCS icon above. - let drafting_alignment_active = - self.snapper.alignment_active() - || (tab.active_grip.is_some() - && (self.polar_mode || self.ortho_mode)); + let drafting_alignment_active = self.snapper.alignment_active() + || (tab.active_grip.is_some() && (self.polar_mode || self.ortho_mode)); let otrack_proj: Option<(glam::Mat4, glam::DVec3, iced::Rectangle)> = if drafting_alignment_active { @@ -814,16 +809,17 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - let point_cursor = tab.active_cmd.as_ref().is_some_and(|cmd| { - !cmd.needs_entity_pick() && !cmd.is_selection_gathering() - }); + let point_cursor = tab + .active_cmd + .as_ref() + .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); // Constraint glyphs are displayed in model space only. let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = if is_paper { Vec::new() } else { let (vw, vh) = sel_ref.vp_size; - let scope = tab.current_sketch_scope(); - match tab.scene.sketch_constraint_set(scope) { + let scope = tab.current_parametric_scope(); + match tab.scene.parametric_constraint_set(scope) { Some(set) if !set.constraints.is_empty() => { let edit_frame = tab.scene.viewport_edit_frame((vw, vh)); let bounds = match &edit_frame { @@ -839,10 +835,10 @@ bg={bg_ms:.1}ms n={view_count}" set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| tab.scene.is_sketch_constraint_visible(scope, c.id)) + .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id)) .filter_map(|c| { let (anchor, outward) = - crate::scene::sketch_constraints::glyph_placement( + crate::scene::parametric_constraints::glyph_placement( &tab.scene.document, c, )?; @@ -862,22 +858,21 @@ bg={bg_ms:.1}ms n={view_count}" eye, bounds, )?; - let direction = (outward_screen - screen) - .normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new( - bounds.x + screen.x, - bounds.y + screen.y, - ); + let direction = + (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = + iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); let label = if self.show_constraint_values { - crate::scene::sketch_constraints::glyph_label(c) + crate::scene::parametric_constraints::glyph_label(c) } else { c.kind.glyph_symbol().to_string() }; - point.x.is_finite().then(|| { - (point, direction.to_array(), label, is_conflicting) - }) + point + .x + .is_finite() + .then(|| (point, direction.to_array(), label, is_conflicting)) }) .collect() } @@ -976,38 +971,38 @@ bg={bg_ms:.1}ms n={view_count}" .as_ref() .map(|c| c.needs_entity_pick() || c.needs_structure_point_pick()) .unwrap_or(false); - let dyn_input_overlay: Option> = - if self.dyn_input - && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && (!tab.dyn_fields.is_empty() || dyn_picks_object) - { - let w = tab.last_cursor_world; - let base = tab.dyn_anchor.or(self.last_point); - let label_screen = tab - .active_cmd - .as_ref() - .and_then(|command| command.dyn_label_point(w)) - .and_then(|world| { - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let (camera, bounds) = tab - .scene - .viewport_edit_frame((vw, vh)) - .unwrap_or_else(|| { - ( - tab.scene.camera.borrow().clone(), - tab.scene.active_model_tile_bounds(vw, vh), - ) - }); - camera.project(world, bounds).and_then(|point| { - (point.x.is_finite() && point.y.is_finite()).then(|| { - iced::Point::new(bounds.x + point.x, bounds.y + point.y) - }) - }) - }); - // A command may drive a typed scalar by mouse (e.g. a - // perpendicular distance to a picked object); show that live - // value in the box until the user types over it. - let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| { + let dyn_input_overlay: Option> = if self.dyn_input + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) + && (!tab.dyn_fields.is_empty() || dyn_picks_object) + { + let w = tab.last_cursor_world; + let base = tab.dyn_anchor.or(self.last_point); + let label_screen = tab + .active_cmd + .as_ref() + .and_then(|command| command.dyn_label_point(w)) + .and_then(|world| { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = + tab.scene.viewport_edit_frame((vw, vh)).unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + camera.project(world, bounds).and_then(|point| { + (point.x.is_finite() && point.y.is_finite()) + .then(|| iced::Point::new(bounds.x + point.x, bounds.y + point.y)) + }) + }); + // A command may drive a typed scalar by mouse (e.g. a + // perpendicular distance to a picked object); show that live + // value in the box until the user types over it. + let live = tab + .active_cmd + .as_ref() + .and_then(|c| c.dyn_live_value(w)) + .or_else(|| { let grip = tab.active_grip.as_ref()?; let action = match grip.mode { crate::scene::pick::grip::GripEditMode::Lengthen => { @@ -1042,147 +1037,140 @@ bg={bg_ms:.1}ms n={view_count}" w, ) }); - let rectangle_values = tab - .active_grip - .as_ref() - .and_then(|grip| grip.rectangle_frame) - .map(|(opposite, width_axis, height_axis)| { - let delta = w - opposite; - (delta.dot(width_axis).abs(), delta.dot(height_axis).abs()) - }); - let rectangle_label_screens = tab - .active_grip - .as_ref() - .and_then(|grip| grip.rectangle_frame) - .and_then(|(opposite, width_axis, height_axis)| { - let delta = w - opposite; - let width = delta.dot(width_axis); - let height = delta.dot(height_axis); - let width_center = opposite + width_axis * (width * 0.5); - let height_center = opposite - + width_axis * width - + height_axis * (height * 0.5); - let rectangle_center = opposite - + width_axis * (width * 0.5) - + height_axis * (height * 0.5); - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let (camera, bounds) = tab - .scene - .viewport_edit_frame((vw, vh)) - .unwrap_or_else(|| { - ( - tab.scene.camera.borrow().clone(), - tab.scene.active_model_tile_bounds(vw, vh), - ) - }); - let project = |point| { - camera.project(point, bounds).map(|screen| iced::Point::new( - bounds.x + screen.x, - bounds.y + screen.y, - )) - }; - let center = project(rectangle_center)?; - let offset_from_center = |side: iced::Point, pixels: f32| { - let dx = side.x - center.x; - let dy = side.y - center.y; - let length = dx.hypot(dy); - if length > 1.0e-3 { - iced::Point::new( - side.x + dx / length * pixels, - side.y + dy / length * pixels, - ) - } else { - side - } - }; - Some(( - offset_from_center(project(width_center)?, 14.0), - offset_from_center(project(height_center)?, 18.0), - )) - }); - let boxes: Vec = tab - .dyn_fields - .iter() - .enumerate() - .map(|(idx, f)| { - let value = match (&f.buffer, live) { - (Some(b), _) => b.clone(), - (None, _) if rectangle_values.is_some() => { - let (width, height) = rectangle_values.unwrap(); - match f.role { - crate::command::DynRole::Width => format!("{width:.4}"), - crate::command::DynRole::Height => format!("{height:.4}"), - _ => String::new(), - } - } - // An angle step with a command-supplied live value - // (ARC span / direction) shows it in degrees. - (None, Some(lv)) if f.component == DynComponent::Angle => { - format!("{lv:.1}") - } - (None, Some(lv)) - if matches!( - f.component, - DynComponent::Scalar | DynComponent::Distance - ) => - { - format!("{lv:.4}") - } - _ => dyn_component_value( - f, - w, - base, - &tab.ucs_xform(), - self.dyn_user_reshaped, - self.dyn_coord_absolute, - ), - }; - crate::ui::overlay::DynBox { - label: f.role.label().to_string(), - value, - active: idx == tab.dyn_active, - locked: f.locked(), - role: f.role, - center: rectangle_label_screens.and_then(|(width, height)| { - match f.role { - crate::command::DynRole::Width => Some(width), - crate::command::DynRole::Height => Some(height), - _ => None, - } - }), + let rectangle_values = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .map(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + (delta.dot(width_axis).abs(), delta.dot(height_axis).abs()) + }); + let rectangle_label_screens = tab + .active_grip + .as_ref() + .and_then(|grip| grip.rectangle_frame) + .and_then(|(opposite, width_axis, height_axis)| { + let delta = w - opposite; + let width = delta.dot(width_axis); + let height = delta.dot(height_axis); + let width_center = opposite + width_axis * (width * 0.5); + let height_center = + opposite + width_axis * width + height_axis * (height * 0.5); + let rectangle_center = + opposite + width_axis * (width * 0.5) + height_axis * (height * 0.5); + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let (camera, bounds) = + tab.scene.viewport_edit_frame((vw, vh)).unwrap_or_else(|| { + ( + tab.scene.camera.borrow().clone(), + tab.scene.active_model_tile_bounds(vw, vh), + ) + }); + let project = |point| { + camera.project(point, bounds).map(|screen| { + iced::Point::new(bounds.x + screen.x, bounds.y + screen.y) + }) + }; + let center = project(rectangle_center)?; + let offset_from_center = |side: iced::Point, pixels: f32| { + let dx = side.x - center.x; + let dy = side.y - center.y; + let length = dx.hypot(dy); + if length > 1.0e-3 { + iced::Point::new( + side.x + dx / length * pixels, + side.y + dy / length * pixels, + ) + } else { + side } - }) - .collect(); - let prompt = tab - .active_cmd - .as_ref() - .map(|c| c.prompt()) - .unwrap_or_default(); - - let tracking_hint = match self.otrack_kind { - Some(crate::snap::TrackingKind::Perpendicular) => { - Some(crate::tr!("common", "perpendicular")) + }; + Some(( + offset_from_center(project(width_center)?, 14.0), + offset_from_center(project(height_center)?, 18.0), + )) + }); + let boxes: Vec = tab + .dyn_fields + .iter() + .enumerate() + .map(|(idx, f)| { + let value = match (&f.buffer, live) { + (Some(b), _) => b.clone(), + (None, _) if rectangle_values.is_some() => { + let (width, height) = rectangle_values.unwrap(); + match f.role { + crate::command::DynRole::Width => format!("{width:.4}"), + crate::command::DynRole::Height => format!("{height:.4}"), + _ => String::new(), + } + } + // An angle step with a command-supplied live value + // (ARC span / direction) shows it in degrees. + (None, Some(lv)) if f.component == DynComponent::Angle => { + format!("{lv:.1}") + } + (None, Some(lv)) + if matches!( + f.component, + DynComponent::Scalar | DynComponent::Distance + ) => + { + format!("{lv:.4}") + } + _ => dyn_component_value( + f, + w, + base, + &tab.ucs_xform(), + self.dyn_user_reshaped, + self.dyn_coord_absolute, + ), + }; + crate::ui::overlay::DynBox { + label: f.role.label().to_string(), + value, + active: idx == tab.dyn_active, + locked: f.locked(), + role: f.role, + center: rectangle_label_screens.and_then(|(width, height)| match f.role { + crate::command::DynRole::Width => Some(width), + crate::command::DynRole::Height => Some(height), + _ => None, + }), } - Some(crate::snap::TrackingKind::Extension) => { - Some(crate::tr!("common", "extension")) - } - _ => None, - }; + }) + .collect(); + let prompt = tab + .active_cmd + .as_ref() + .map(|c| c.prompt()) + .unwrap_or_default(); - Some(crate::ui::overlay::dynamic_input_overlay( - tab.last_cursor_screen, - tab.last_point_screen, - tab.dyn_ref_screen, - label_screen, - tab.dyn_guide, - boxes, - prompt, - tracking_hint, - )) - } else { - None + let tracking_hint = match self.otrack_kind { + Some(crate::snap::TrackingKind::Perpendicular) => { + Some(crate::tr!("common", "perpendicular")) + } + Some(crate::snap::TrackingKind::Extension) => { + Some(crate::tr!("common", "extension")) + } + _ => None, }; + Some(crate::ui::overlay::dynamic_input_overlay( + tab.last_cursor_screen, + tab.last_point_screen, + tab.dyn_ref_screen, + label_screen, + tab.dyn_guide, + boxes, + prompt, + tracking_hint, + )) + } else { + None + }; + mark("viewport_stack"); let mut viewport_stack = if tab.is_start || self.layout_settling { // Start tab: only the welcome widget over a flat background. @@ -1246,150 +1234,189 @@ bg={bg_ms:.1}ms n={view_count}" }; if !thumbnail_capture_clean && !self.layout_settling { - // Per-pane input pane_grid goes ABOVE the crosshair overlay so it - // receives mouse events (the overlay's `Hidden` cursor would otherwise - // starve any layer beneath it). The controls bar is pushed on top of it. - if let Some(input) = model_input_layer { - viewport_stack = viewport_stack.push(input); - } + // Per-pane input pane_grid goes ABOVE the crosshair overlay so it + // receives mouse events (the overlay's `Hidden` cursor would otherwise + // starve any layer beneath it). The controls bar is pushed on top of it. + if let Some(input) = model_input_layer { + viewport_stack = viewport_stack.push(input); + } - // Model-space render-mode picker, top-left. Sits ABOVE the - // viewport mouse_area so clicks inside its bounds reach it - // instead of the shader behind it; `opaque` stops them bubbling - // further. Outside the chip the Fill container is transparent so - // viewport drawing / selection is unaffected. In a paper layout - // the active viewport gets its own picker (below) instead. - if !is_paper && !tab.is_start { - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let rect = tab.scene.active_model_tile_bounds(vw, vh); - // Unified control chip: split buttons + render-mode picker + - // grid / grid-snap toggles, for the active Model tile. - let bar = viewport_controls( - tab.render_mode, - self.show_grid, - self.snapper.grid_snap(), - true, - tab.scene.model_tiles.borrow().len(), - self.render_mode_menu_open, - self.render_mode_preview, - ); - // Adaptive: DensitySwap measures the bar's real width every frame - // (reported into `render_bar_w`, which the ViewCube reads to decide - // overlap) and swaps it for an empty spacer only when it no longer - // fits the tile. The fixed-width container bounds that fit decision - // to the tile, not the whole canvas. - let adaptive: Element<'_, Message> = DensitySwap::new(vec![ - iced::widget::opaque(bar), - Space::new() - .width(iced::Length::Fixed(0.0)) - .height(iced::Length::Fixed(0.0)) - .into(), - ]) - .report_width0(self.render_bar_w.clone()) - .report_width0(tab.scene.model_pane_min_reporter()) - .into(); - // Pin the bar to the active model tile's top-left corner so it - // follows the active panel in a tiled layout. - let bar_layer = iced::widget::pin( - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ) - .position(iced::Point::new(rect.x.max(0.0), rect.y.max(0.0))); - viewport_stack = viewport_stack.push(bar_layer); - } + // Model-space render-mode picker, top-left. Sits ABOVE the + // viewport mouse_area so clicks inside its bounds reach it + // instead of the shader behind it; `opaque` stops them bubbling + // further. Outside the chip the Fill container is transparent so + // viewport drawing / selection is unaffected. In a paper layout + // the active viewport gets its own picker (below) instead. + if !is_paper && !tab.is_start { + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let rect = tab.scene.active_model_tile_bounds(vw, vh); + // Unified control chip: split buttons + render-mode picker + + // grid / grid-snap toggles, for the active Model tile. + let bar = viewport_controls( + tab.render_mode, + self.show_grid, + self.snapper.grid_snap(), + true, + tab.scene.model_tiles.borrow().len(), + self.render_mode_menu_open, + self.render_mode_preview, + ); + // Adaptive: DensitySwap measures the bar's real width every frame + // (reported into `render_bar_w`, which the ViewCube reads to decide + // overlap) and swaps it for an empty spacer only when it no longer + // fits the tile. The fixed-width container bounds that fit decision + // to the tile, not the whole canvas. + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .report_width0(tab.scene.model_pane_min_reporter()) + .into(); + // Pin the bar to the active model tile's top-left corner so it + // follows the active panel in a tiled layout. + let bar_layer = iced::widget::pin( + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ) + .position(iced::Point::new(rect.x.max(0.0), rect.y.max(0.0))); + viewport_stack = viewport_stack.push(bar_layer); + } - // Active paper-space viewport overlays: a render-mode picker in - // its top-left corner and a ViewCube hit area in its top-right, - // both layered ABOVE the viewport mouse_area so they receive - // clicks (the shader viewport sits below it). Positioned with - // leading Spaces sized to the viewport's screen rectangle. - mark("active_vp_rect"); - let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = - if is_paper && !tab.is_start { - tab.scene.active_viewport.and_then(|h| { - let (cw, ch) = tab.scene.selection.borrow().vp_size; - tab.scene - .viewport_screen_rect(h, (cw, ch)) - .map(|rect| (h, rect)) - }) - } else { - None - }; - if let Some((active_vp, rect)) = active_vp_rect { - // Clip the outline to the visible canvas. Clamping only the origin - // (max(0.0)) while keeping the full width/height shifted the whole - // outline inward when the viewport ran off the top/left edge, so - // its drawn border no longer matched the real viewport — clicks - // that looked outside landed in (and activated) another viewport. - let (cw, ch) = tab.scene.selection.borrow().vp_size; - let x = rect.x.max(0.0); - let y = rect.y.max(0.0); - let vw = ((rect.x + rect.width).min(cw) - x).max(1.0); - let vh = ((rect.y + rect.height).min(ch) - y).max(1.0); - // Highlight the active viewport with a 2-px border so its - // boundary is always visible over the GPU shader. - const VP_BORDER: Color = Color { - r: 0.18, - g: 0.52, - b: 0.95, - a: 1.0, - }; - let border_frame = container( - Space::new() - .width(iced::Length::Fixed(vw)) - .height(iced::Length::Fixed(vh)), - ) - .style(move |_: &Theme| container::Style { - border: iced::Border { - color: VP_BORDER, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - }); - let border_layer = - iced::widget::pin(border_frame).position(iced::Point::new(x, y)); - viewport_stack = viewport_stack.push(border_layer); + // Active paper-space viewport overlays: a render-mode picker in + // its top-left corner and a ViewCube hit area in its top-right, + // both layered ABOVE the viewport mouse_area so they receive + // clicks (the shader viewport sits below it). Positioned with + // leading Spaces sized to the viewport's screen rectangle. + mark("active_vp_rect"); + let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = + if is_paper && !tab.is_start { + tab.scene.active_viewport.and_then(|h| { + let (cw, ch) = tab.scene.selection.borrow().vp_size; + tab.scene + .viewport_screen_rect(h, (cw, ch)) + .map(|rect| (h, rect)) + }) + } else { + None + }; + if let Some((active_vp, rect)) = active_vp_rect { + // Clip the outline to the visible canvas. Clamping only the origin + // (max(0.0)) while keeping the full width/height shifted the whole + // outline inward when the viewport ran off the top/left edge, so + // its drawn border no longer matched the real viewport — clicks + // that looked outside landed in (and activated) another viewport. + let (cw, ch) = tab.scene.selection.borrow().vp_size; + let x = rect.x.max(0.0); + let y = rect.y.max(0.0); + let vw = ((rect.x + rect.width).min(cw) - x).max(1.0); + let vh = ((rect.y + rect.height).min(ch) - y).max(1.0); + // Highlight the active viewport with a 2-px border so its + // boundary is always visible over the GPU shader. + const VP_BORDER: Color = Color { + r: 0.18, + g: 0.52, + b: 0.95, + a: 1.0, + }; + let border_frame = container( + Space::new() + .width(iced::Length::Fixed(vw)) + .height(iced::Length::Fixed(vh)), + ) + .style(move |_: &Theme| container::Style { + border: iced::Border { + color: VP_BORDER, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + let border_layer = iced::widget::pin(border_frame).position(iced::Point::new(x, y)); + viewport_stack = viewport_stack.push(border_layer); - let vp_mode = tab - .scene - .active_viewport_render_mode() - .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D); - // Adaptive (same as model): the picker measures its real width into - // `render_bar_w` and swaps to an empty spacer only when the viewport - // can't hold it; the ViewCube reads that width to decide overlap. - let bar = viewport_controls( - vp_mode, - self.show_grid, - self.snapper.grid_snap(), - false, - 0, - self.render_mode_menu_open, - self.render_mode_preview, - ); - let adaptive: Element<'_, Message> = DensitySwap::new(vec![ - iced::widget::opaque(bar), - Space::new() - .width(iced::Length::Fixed(0.0)) - .height(iced::Length::Fixed(0.0)) - .into(), - ]) - .report_width0(self.render_bar_w.clone()) - .into(); - let picker_layer = iced::widget::pin( - container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), - ) - .position(iced::Point::new(x + 4.0, y + 4.0)); - viewport_stack = viewport_stack.push(picker_layer); + let vp_mode = tab + .scene + .active_viewport_render_mode() + .unwrap_or(acadrust::entities::ViewportRenderMode::Wireframe2D); + // Adaptive (same as model): the picker measures its real width into + // `render_bar_w` and swaps to an empty spacer only when the viewport + // can't hold it; the ViewCube reads that width to decide overlap. + let bar = viewport_controls( + vp_mode, + self.show_grid, + self.snapper.grid_snap(), + false, + 0, + self.render_mode_menu_open, + self.render_mode_preview, + ); + let adaptive: Element<'_, Message> = DensitySwap::new(vec![ + iced::widget::opaque(bar), + Space::new() + .width(iced::Length::Fixed(0.0)) + .height(iced::Length::Fixed(0.0)) + .into(), + ]) + .report_width0(self.render_bar_w.clone()) + .into(); + let picker_layer = iced::widget::pin( + container(adaptive).width(iced::Length::Fixed(rect.width.max(1.0))), + ) + .position(iced::Point::new(x + 4.0, y + 4.0)); + viewport_stack = viewport_stack.push(picker_layer); - // Hide the ViewCube first — before the render bar — when they collide. - if viewcube_visible { + // Hide the ViewCube first — before the render bar — when they collide. + if viewcube_visible { + let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); + let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); + + let controls = iced::widget::pin(viewcube_nav_controls(Some(active_vp))) + .position(iced::Point::new(cube_x, cube_y)); + viewport_stack = viewport_stack.push(controls); + + let ucs_current = tab + .active_ucs + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or_default(); + let ucs_names: Vec = tab + .scene + .document + .ucss + .iter() + .map(|u| u.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + let picker = iced::widget::pin(iced::widget::opaque(viewcube_ucs_picker( + ucs_current, + ucs_names, + ))) + .position(iced::Point::new( + cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, + cube_y + VIEWCUBE_HIT_SIZE + 10.0, + )); + viewport_stack = viewport_stack.push(picker); + } + } + + if viewcube_visible && !is_paper { + // Place the ViewCube hit area in the active model tile's top-right + // corner so it tracks the active panel in a tiled layout. The hit + // test in update.rs already maps clicks through the active tile. + let (vw, vh) = tab.scene.selection.borrow().vp_size; + let rect = tab.scene.active_model_tile_bounds(vw, vh); let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - let controls = iced::widget::pin(viewcube_nav_controls(Some(active_vp))) + // Cube hit area + nav controls (home / roll / nudge) as one layer. + let controls = iced::widget::pin(viewcube_nav_controls(None)) .position(iced::Point::new(cube_x, cube_y)); viewport_stack = viewport_stack.push(controls); + // WCS / named-UCS selector under the cube. let ucs_current = tab .active_ucs .as_ref() @@ -1408,441 +1435,401 @@ bg={bg_ms:.1}ms n={view_count}" ucs_names, ))) .position(iced::Point::new( - cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, - cube_y + VIEWCUBE_HIT_SIZE + 10.0, - )); - viewport_stack = viewport_stack.push(picker); - } - } - - if viewcube_visible && !is_paper { - // Place the ViewCube hit area in the active model tile's top-right - // corner so it tracks the active panel in a tiled layout. The hit - // test in update.rs already maps clicks through the active tile. - let (vw, vh) = tab.scene.selection.borrow().vp_size; - let rect = tab.scene.active_model_tile_bounds(vw, vh); - let cube_x = (rect.x + rect.width - VIEWCUBE_HIT_SIZE - VIEWCUBE_PAD).max(0.0); - let cube_y = (rect.y + VIEWCUBE_PAD).max(0.0); - - // Cube hit area + nav controls (home / roll / nudge) as one layer. - let controls = iced::widget::pin(viewcube_nav_controls(None)) - .position(iced::Point::new(cube_x, cube_y)); - viewport_stack = viewport_stack.push(controls); - - // WCS / named-UCS selector under the cube. - let ucs_current = tab - .active_ucs - .as_ref() - .map(|u| u.name.clone()) - .unwrap_or_default(); - let ucs_names: Vec = tab - .scene - .document - .ucss - .iter() - .map(|u| u.name.clone()) - .filter(|n| !n.is_empty()) - .collect(); - let picker = iced::widget::pin(iced::widget::opaque(viewcube_ucs_picker( - ucs_current, - ucs_names, - ))) - .position(iced::Point::new( cube_x + VIEWCUBE_HIT_SIZE * 0.5 - UCS_PICKER_W * 0.5, cube_y + VIEWCUBE_HIT_SIZE + 10.0, )); - viewport_stack = viewport_stack.push(picker); - } - - if let Some(dyn_ol) = dyn_input_overlay { - if !tab.is_start { - viewport_stack = viewport_stack.push(dyn_ol); + viewport_stack = viewport_stack.push(picker); } - } - if let Some(popup) = self.grip_popup.as_ref() { - if !tab.is_start { - let labels: Vec = popup - .items - .iter() - .map(|item| { - let (mark, raw) = item - .label - .strip_prefix("✓ ") - .map_or(("", item.label), |raw| ("✓ ", raw)); - format!("{mark}{}", crate::i18n::translate(raw)) - }) - .collect(); - let max_len = labels - .iter() - .map(|label| label.chars().count()) - .max() - .unwrap_or(8) as f32; - let row_w = max_len * 7.0 + 24.0; - let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); - for (idx, label) in labels.into_iter().enumerate() { - let is_sel = idx == popup.selected; - let btn = button(text(label).size(12)) - .on_press(Message::GripMenuPick(idx)) - .padding([3, 10]) - .width(Fill) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let pair = match (is_sel, status) { - (true, _) => Some(palette.primary.strong), - (_, iced::widget::button::Status::Hovered) => { - Some(palette.background.strong) - } - _ => None, - }; - iced::widget::button::Style { - background: pair.map(|p| Background::Color(p.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - text_color: pair - .map(|p| p.text) - .unwrap_or(palette.background.base.text), - ..Default::default() - } - }); - col = col.push(btn); + if let Some(dyn_ol) = dyn_input_overlay { + if !tab.is_start { + viewport_stack = viewport_stack.push(dyn_ol); } - let menu_panel = container(col) - .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }); - // Offset the menu by 12 px so the cursor doesn't land on - // the first item immediately, matching the right-click - // context menu's "panel below the click point" feel. - let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); - viewport_stack = - viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into())); } - } - // Dynamic-block visibility-state dropdown. - if let Some(popup) = self.visibility_popup.as_ref() { - if !tab.is_start { - let max_len = popup - .items - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(4) as f32; - // +2 chars for the leading "✓ " / " " marker column. - let row_w = (max_len + 2.0) * 7.0 + 24.0; - let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); - for (idx, name) in popup.items.iter().enumerate() { - let is_cur = popup.current == Some(idx); - let mark: Element<'_, Message> = if is_cur { - crate::ui::icons::themed_check_cell(true) - } else { - Space::new().width(11).into() - }; - let btn = button( - row![ - container(mark).width(16), - text(name).size(12), - ] - .spacing(2) - .align_y(iced::Center), - ) - .on_press(Message::VisibilityPick(idx)) - .padding([3, 10]) - .width(Fill) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - iced::widget::button::Style { - background: matches!( - status, - iced::widget::button::Status::Hovered - ) - .then_some(Background::Color(palette.primary.weak.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - text_color: palette.background.base.text, - ..Default::default() - } - }); - col = col.push(btn); - } - let panel = container(iced::widget::scrollable(col).height(iced::Length::Shrink)) - .height(iced::Length::Fit.max(360.0)) - .padding(2) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }); - let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); - viewport_stack = - viewport_stack.push(position_canvas_overlay(anchor, panel.into())); - } - } - - // Paper-space context actions: a right-edge vertical toolbar - // (viewport / page setup / plot) instead of a contextual ribbon tab. - if is_paper && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::layout::paper_space_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // In-place block edit (REFEDIT): right-edge toolbar with Save / Discard - // so the edit can be finished by clicking. (#136) - if tab.refedit_session.is_some() && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::draw::modify::refedit::refedit_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // BEDIT block editor: right-edge Save Block / Discard toolbar (#261). - if tab.active_block_edit.is_some() && !tab.is_start { - if let Some(tb) = crate::ui::side_toolbar::view( - &crate::modules::draw::modify::block_edit::block_edit_tools(), - ) { - viewport_stack = viewport_stack.push(tb); - } - } - - // Reserve the overlaid command line when placing cursor-anchored panels. - mark("chrome"); - let command_line_inset = if self.command_line.history_open { - self.command_line.history_height.clamp( - crate::ui::command_line::HISTORY_HEIGHT_MIN, - crate::ui::command_line::history_max_height(self.win_size.1), - ) + 72.0 - } else { - 34.0 - }; - - // Quick Properties: stay near the selection cursor, flipping around - // it as needed to remain inside the visible drawing area. - if self.quick_properties && !tab.is_start { - if let Some(panel) = tab.properties.quick_view() { - viewport_stack = viewport_stack.push(position_canvas_overlay_near_cursor( - self.quick_properties_anchor, - command_line_inset, - panel, - )); - } - } - - // Shared performance panel: terminal PERF lines plus the current - // tessellation summary. Copy / Clear mirror the command-history panel. - if self.perf_hud { - let s = &tab.scene; - let perf_w = if render_bar_w.is_finite() && render_bar_w > 1.0 { - render_bar_w - } else { - 320.0 - }; - let summary = format!( - "tess {:.1} ms · {} wires · epoch {}", - s.last_tess_ms.get(), - s.last_tess_wires.get(), - s.geometry_epoch, - ); - let trace = crate::perf::snapshot_tail_text(80); - let trace = if trace.is_empty() { - t!("No samples yet").into_owned() - } else { - trace - }; - let perf_button_style = |theme: &Theme, status: button::Status| { - let palette = theme.palette(); - let pair = if matches!(status, button::Status::Hovered) { - palette.background.strong - } else { - palette.background.weak - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - } - }; - let copy_btn = button( - row![ - crate::ui::icons::themed_primary(crate::ui::icons::COPY, 11.0), - text(t!("Copy")).size(11), - ] - .spacing(4) - .align_y(iced::Center), - ) - .on_press(Message::PerfCopy) - .style(perf_button_style) - .padding([2, 6]); - let clear_btn = button( - row![ - crate::ui::icons::themed_danger(crate::ui::icons::TRASH, 11.0), - text(t!("Clear")).size(11), - ] - .spacing(4) - .align_y(iced::Center), - ) - .on_press(Message::PerfClear) - .style(perf_button_style) - .padding([2, 6]); - let header = row![ - text(t!("PERF")).size(12).style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().success.base.color), - }), - Space::new().width(iced::Length::Fill), - copy_btn, - clear_btn, - ] - .spacing(6) - .align_y(iced::Center); - let log = scrollable(text(trace).size(11)) - .height(iced::Length::Fixed(220.0)) - .width(iced::Length::Fill); - let panel = container( - column![ - header, - text(summary).size(11).style(|theme: &Theme| iced::widget::text::Style { - color: Some(theme.palette().success.base.color), - }), - log, - ] - .spacing(5), - ) - .width(iced::Length::Fixed(perf_w)) - .padding(6) - .style(|_: &Theme| container::Style { - background: None, - border: Border::default(), - ..Default::default() - }); - viewport_stack = viewport_stack.push(position_canvas_overlay( - iced::Point::new(12.0, 40.0), - panel.into(), - )); - } - - // Selection-cycling list box: pick among overlapping objects. - if let Some((pt, cands)) = &self.cycle_candidates { - if !tab.is_start { - let items: Vec = cands - .iter() - .filter_map(|&h| { - tab.scene.document.get_entity(h).map(|e| { - let style = crate::scene::view::render::render_style_for_viewport( - &tab.scene.document, - e, - None, - ); - crate::ui::popup::cycle_popup::CycleCandidate { - handle: h, - type_name: crate::entities::traits::entity_type_name(e) - .to_string(), - layer: e.common().layer.clone(), - color: style.0, - } + if let Some(popup) = self.grip_popup.as_ref() { + if !tab.is_start { + let labels: Vec = popup + .items + .iter() + .map(|item| { + let (mark, raw) = item + .label + .strip_prefix("✓ ") + .map_or(("", item.label), |raw| ("✓ ", raw)); + format!("{mark}{}", crate::i18n::translate(raw)) }) - }) - .collect(); - if !items.is_empty() { - viewport_stack = viewport_stack - .push(crate::ui::popup::cycle_popup::cycle_popup_overlay(*pt, items)); + .collect(); + let max_len = labels + .iter() + .map(|label| label.chars().count()) + .max() + .unwrap_or(8) as f32; + let row_w = max_len * 7.0 + 24.0; + let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); + for (idx, label) in labels.into_iter().enumerate() { + let is_sel = idx == popup.selected; + let btn = button(text(label).size(12)) + .on_press(Message::GripMenuPick(idx)) + .padding([3, 10]) + .width(Fill) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (is_sel, status) { + (true, _) => Some(palette.primary.strong), + (_, iced::widget::button::Status::Hovered) => { + Some(palette.background.strong) + } + _ => None, + }; + iced::widget::button::Style { + background: pair.map(|p| Background::Color(p.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text), + ..Default::default() + } + }); + col = col.push(btn); + } + let menu_panel = container(col).padding(2).style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }); + // Offset the menu by 12 px so the cursor doesn't land on + // the first item immediately, matching the right-click + // context menu's "panel below the click point" feel. + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = + viewport_stack.push(position_canvas_overlay(anchor, menu_panel.into())); } } - } - // Right-click context menu. Lives inside the viewport stack so - // the cursor position (canvas-relative) anchors the menu under - // the cursor instead of drifting into window-relative space. - if !tab.is_start { - let (ctx_pos, draworder_open) = { - let sel = tab.scene.selection.borrow(); - (sel.context_menu, sel.draworder_submenu) + // Dynamic-block visibility-state dropdown. + if let Some(popup) = self.visibility_popup.as_ref() { + if !tab.is_start { + let max_len = popup + .items + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(4) as f32; + // +2 chars for the leading "✓ " / " " marker column. + let row_w = (max_len + 2.0) * 7.0 + 24.0; + let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); + for (idx, name) in popup.items.iter().enumerate() { + let is_cur = popup.current == Some(idx); + let mark: Element<'_, Message> = if is_cur { + crate::ui::icons::themed_check_cell(true) + } else { + Space::new().width(11).into() + }; + let btn = button( + row![container(mark).width(16), text(name).size(12),] + .spacing(2) + .align_y(iced::Center), + ) + .on_press(Message::VisibilityPick(idx)) + .padding([3, 10]) + .width(Fill) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + iced::widget::button::Style { + background: matches!(status, iced::widget::button::Status::Hovered) + .then_some(Background::Color(palette.primary.weak.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + text_color: palette.background.base.text, + ..Default::default() + } + }); + col = col.push(btn); + } + let panel = + container(iced::widget::scrollable(col).height(iced::Length::Shrink)) + .height(iced::Length::Fit.max(360.0)) + .padding(2) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.weak.color, + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }); + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = + viewport_stack.push(position_canvas_overlay(anchor, panel.into())); + } + } + + // Paper-space context actions: a right-edge vertical toolbar + // (viewport / page setup / plot) instead of a contextual ribbon tab. + if is_paper && !tab.is_start { + if let Some(tb) = + crate::ui::side_toolbar::view(&crate::modules::layout::paper_space_tools()) + { + viewport_stack = viewport_stack.push(tb); + } + } + + // In-place block edit (REFEDIT): right-edge toolbar with Save / Discard + // so the edit can be finished by clicking. (#136) + if tab.refedit_session.is_some() && !tab.is_start { + if let Some(tb) = crate::ui::side_toolbar::view( + &crate::modules::draw::modify::refedit::refedit_tools(), + ) { + viewport_stack = viewport_stack.push(tb); + } + } + + // BEDIT block editor: right-edge Save Block / Discard toolbar (#261). + if tab.active_block_edit.is_some() && !tab.is_start { + if let Some(tb) = crate::ui::side_toolbar::view( + &crate::modules::draw::modify::block_edit::block_edit_tools(), + ) { + viewport_stack = viewport_stack.push(tb); + } + } + + // Reserve the overlaid command line when placing cursor-anchored panels. + mark("chrome"); + let command_line_inset = if self.command_line.history_open { + self.command_line.history_height.clamp( + crate::ui::command_line::HISTORY_HEIGHT_MIN, + crate::ui::command_line::history_max_height(self.win_size.1), + ) + 72.0 + } else { + 34.0 }; - if let Some(p) = ctx_pos { - let has_cmd = tab.active_cmd.is_some(); - // Same guard as typed MTP/M2P and SnapOverrideMtp. - let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { - (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) - && !c.needs_entity_pick() - }); - let has_selection = !tab.scene.selected.is_empty(); - let isolation_active = tab.scene.is_isolation_active(); - let last_cmds: Vec = self - .command_line - .recent_commands - .iter() - .rev() - .take(3) - .cloned() - .collect(); - viewport_stack = viewport_stack.push(viewport_context_menu_overlay( - p, - command_line_inset, - has_cmd, - has_selection, - isolation_active, - last_cmds, - draworder_open, - has_point_step, - )); - } - } - // In-place MText editor (toolbar + text area), anchored at the - // insertion-point click. - if !tab.is_start { - let canvas = tab.scene.selection.borrow().vp_size; - if let Some(ed) = &self.mtext_editor { - let styles: Vec = tab - .scene - .document - .text_styles - .iter() - .map(|s| s.name.clone()) - .collect(); - viewport_stack = viewport_stack.push(mtext_editor_overlay( - ed, - styles, - self.modal_offset, - self.modal_resize, - self.modal_content_size, + // Quick Properties: stay near the selection cursor, flipping around + // it as needed to remain inside the visible drawing area. + if self.quick_properties && !tab.is_start { + if let Some(panel) = tab.properties.quick_view() { + viewport_stack = viewport_stack.push(position_canvas_overlay_near_cursor( + self.quick_properties_anchor, + command_line_inset, + panel, + )); + } + } + + // Shared performance panel: terminal PERF lines plus the current + // tessellation summary. Copy / Clear mirror the command-history panel. + if self.perf_hud { + let s = &tab.scene; + let perf_w = if render_bar_w.is_finite() && render_bar_w > 1.0 { + render_bar_w + } else { + 320.0 + }; + let summary = format!( + "tess {:.1} ms · {} wires · epoch {}", + s.last_tess_ms.get(), + s.last_tess_wires.get(), + s.geometry_epoch, + ); + let trace = crate::perf::snapshot_tail_text(80); + let trace = if trace.is_empty() { + t!("No samples yet").into_owned() + } else { + trace + }; + let perf_button_style = |theme: &Theme, status: button::Status| { + let palette = theme.palette(); + let pair = if matches!(status, button::Status::Hovered) { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }; + let copy_btn = button( + row![ + crate::ui::icons::themed_primary(crate::ui::icons::COPY, 11.0), + text(t!("Copy")).size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfCopy) + .style(perf_button_style) + .padding([2, 6]); + let clear_btn = button( + row![ + crate::ui::icons::themed_danger(crate::ui::icons::TRASH, 11.0), + text(t!("Clear")).size(11), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::PerfClear) + .style(perf_button_style) + .padding([2, 6]); + let header = row![ + text(t!("PERF")) + .size(12) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().success.base.color), + }), + Space::new().width(iced::Length::Fill), + copy_btn, + clear_btn, + ] + .spacing(6) + .align_y(iced::Center); + let log = scrollable(text(trace).size(11)) + .height(iced::Length::Fixed(220.0)) + .width(iced::Length::Fill); + let panel = container( + column![ + header, + text(summary) + .size(11) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().success.base.color), + }), + log, + ] + .spacing(5), + ) + .width(iced::Length::Fixed(perf_w)) + .padding(6) + .style(|_: &Theme| container::Style { + background: None, + border: Border::default(), + ..Default::default() + }); + viewport_stack = viewport_stack.push(position_canvas_overlay( + iced::Point::new(12.0, 40.0), + panel.into(), )); } - if let Some(ed) = &self.text_inline { - viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); + + // Selection-cycling list box: pick among overlapping objects. + if let Some((pt, cands)) = &self.cycle_candidates { + if !tab.is_start { + let items: Vec = cands + .iter() + .filter_map(|&h| { + tab.scene.document.get_entity(h).map(|e| { + let style = crate::scene::view::render::render_style_for_viewport( + &tab.scene.document, + e, + None, + ); + crate::ui::popup::cycle_popup::CycleCandidate { + handle: h, + type_name: crate::entities::traits::entity_type_name(e) + .to_string(), + layer: e.common().layer.clone(), + color: style.0, + } + }) + }) + .collect(); + if !items.is_empty() { + viewport_stack = viewport_stack.push( + crate::ui::popup::cycle_popup::cycle_popup_overlay(*pt, items), + ); + } + } + } + + // Right-click context menu. Lives inside the viewport stack so + // the cursor position (canvas-relative) anchors the menu under + // the cursor instead of drifting into window-relative space. + if !tab.is_start { + let (ctx_pos, draworder_open) = { + let sel = tab.scene.selection.borrow(); + (sel.context_menu, sel.draworder_submenu) + }; + if let Some(p) = ctx_pos { + let has_cmd = tab.active_cmd.is_some(); + // Same guard as typed MTP/M2P and SnapOverrideMtp. + let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { + (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) + && !c.needs_entity_pick() + }); + let has_selection = !tab.scene.selected.is_empty(); + let isolation_active = tab.scene.is_isolation_active(); + let last_cmds: Vec = self + .command_line + .recent_commands + .iter() + .rev() + .take(3) + .cloned() + .collect(); + viewport_stack = viewport_stack.push(viewport_context_menu_overlay( + p, + command_line_inset, + has_cmd, + has_selection, + isolation_active, + last_cmds, + draworder_open, + has_point_step, + )); + } + } + + // In-place MText editor (toolbar + text area), anchored at the + // insertion-point click. + if !tab.is_start { + let canvas = tab.scene.selection.borrow().vp_size; + if let Some(ed) = &self.mtext_editor { + let styles: Vec = tab + .scene + .document + .text_styles + .iter() + .map(|s| s.name.clone()) + .collect(); + viewport_stack = viewport_stack.push(mtext_editor_overlay( + ed, + styles, + self.modal_offset, + self.modal_resize, + self.modal_content_size, + )); + } + if let Some(ed) = &self.text_inline { + viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); + } } - } } // Docked side panels (Properties, block palette, future palettes) live @@ -1859,20 +1846,19 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, } }; - let edge_stack = - |side: crate::app::config::DockSide| -> Option> { - let ids: Vec = match side { - crate::app::config::DockSide::Left => self.dock.left.clone(), - crate::app::config::DockSide::Right => self.dock.right.clone(), - } - .into_iter() - .filter(|id| visible_panel(*id)) - .collect(); - if ids.is_empty() { - return None; - } - Some(self.build_edge_stack(side, &ids, tab)) - }; + let edge_stack = |side: crate::app::config::DockSide| -> Option> { + let ids: Vec = match side { + crate::app::config::DockSide::Left => self.dock.left.clone(), + crate::app::config::DockSide::Right => self.dock.right.clone(), + } + .into_iter() + .filter(|id| visible_panel(*id)) + .collect(); + if ids.is_empty() { + return None; + } + Some(self.build_edge_stack(side, &ids, tab)) + }; let left_edge = edge_stack(crate::app::config::DockSide::Left); let right_edge = edge_stack(crate::app::config::DockSide::Right); @@ -1900,24 +1886,19 @@ bg={bg_ms:.1}ms n={view_count}" | crate::scene::pick::grip::GripEditMode::MoveParallel ) }); - let dyn_capturing = - (self.dyn_input - && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && !tab.dyn_fields.is_empty() - && !interactive_value_grip) - || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) - || self.text_inline.is_some(); + let dyn_capturing = (self.dyn_input + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) + && !tab.dyn_fields.is_empty() + && !interactive_value_grip) + || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) + || self.text_inline.is_some(); // The workspace row is: left edge stack, viewport, right edge stack. let mut parts: Vec> = Vec::new(); if let Some(e) = left_edge { parts.push(e); } parts.push( - crate::ui::wrap_bar::PosReport::new( - VIEWPORT_CAPTURE_BOUNDS_ID, - viewport_stack, - ) - .into(), + crate::ui::wrap_bar::PosReport::new(VIEWPORT_CAPTURE_BOUNDS_ID, viewport_stack).into(), ); if let Some(e) = right_edge { parts.push(e); @@ -1994,9 +1975,7 @@ bg={bg_ms:.1}ms n={view_count}" .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color( - palette.primary.base.color, - )), + background: Some(Background::Color(palette.primary.base.color)), text_color: Some(palette.primary.base.text), ..Default::default() } @@ -2007,9 +1986,7 @@ bg={bg_ms:.1}ms n={view_count}" .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color( - palette.background.base.color, - )), + background: Some(Background::Color(palette.background.base.color)), border: Border { color: palette.primary.base.color, width: 2.0, @@ -2023,12 +2000,8 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Fill) .align_x(match side { - crate::app::config::DockSide::Left => { - iced::alignment::Horizontal::Left - } - crate::app::config::DockSide::Right => { - iced::alignment::Horizontal::Right - } + crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, + crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, }) .align_y(iced::alignment::Vertical::Top) .padding(iced::Padding { @@ -2043,9 +2016,7 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Length::Fixed(3.0)) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().primary.base.color, - )), + background: Some(Background::Color(theme.palette().primary.base.color)), ..Default::default() }), ) @@ -2065,12 +2036,8 @@ bg={bg_ms:.1}ms n={view_count}" .width(Fill) .height(Fill) .align_x(match side { - crate::app::config::DockSide::Left => { - iced::alignment::Horizontal::Left - } - crate::app::config::DockSide::Right => { - iced::alignment::Horizontal::Right - } + crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, + crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, }); stack![workspace, preview].width(Fill).height(Fill).into() } else { @@ -2215,11 +2182,7 @@ bg={bg_ms:.1}ms n={view_count}" current_scale_name: tab.scene.displayed_annotation_scale_name(), scale_list: tab.scene.scale_picker_list(), has_selection: !tab.scene.selected.is_empty(), - selection_types: tab - .scene - .entity_type_names_in_layout() - .as_ref() - .clone(), + selection_types: tab.scene.entity_type_names_in_layout().as_ref().clone(), selection_filter: &tab.scene.selection_filter, tooltip_hidden: self.status_menu_tooltip_hidden, }; @@ -2262,8 +2225,13 @@ bg={bg_ms:.1}ms n={view_count}" self.selection_cycling, &self.statusbar_config, status_menu_data, - tab.scene.sketch_constraint_set(tab.current_sketch_scope()).and_then(|s| s.dof), - tab.scene.sketch_constraint_set(tab.current_sketch_scope()).map(|s| s.conflicts.len()).unwrap_or(0), + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .and_then(|s| s.dof), + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .map(|s| s.conflicts.len()) + .unwrap_or(0), &self.gpu_status, ) }) @@ -2271,9 +2239,7 @@ bg={bg_ms:.1}ms n={view_count}" .height(Fill) }) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color - )), + background: Some(Background::Color(theme.palette().background.base.color)), ..Default::default() }) .width(Fill) @@ -2289,12 +2255,12 @@ bg={bg_ms:.1}ms n={view_count}" ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); - let snap_override_layer: Element<'_, Message> = - if let Some(pos) = self.snap_override_popup { - overlay::snap_override_overlay(pos) - } else { - iced::widget::Space::new().width(0).height(0).into() - }; + let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup + { + overlay::snap_override_overlay(pos) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { qselect_overlay( @@ -2329,27 +2295,26 @@ bg={bg_ms:.1}ms n={view_count}" // If the Plot Style editor was launched from PLOT, keep the Plot dialog // rendered underneath as its blocked parent. - let modal_underlay: Element<'_, Message> = - if self.active_modal == Some(super::ModalKind::Plotstyle) { - if let Some((plot_offset, plot_resize)) = - self.plotstyle_parent_plot_geometry.as_ref() - { - let plot_content = self.plot_modal_content(*plot_resize); + let modal_underlay: Element<'_, Message> = if self.active_modal + == Some(super::ModalKind::Plotstyle) + { + if let Some((plot_offset, plot_resize)) = self.plotstyle_parent_plot_geometry.as_ref() { + let plot_content = self.plot_modal_content(*plot_resize); - crate::ui::modal::modal( - composed, - crate::tr!("modal", "plot"), - plot_content, - Message::CloseModal, - *plot_offset, - crate::ui::modal::ModalOptions::STANDARD, - ) - } else { - composed.into() - } + crate::ui::modal::modal( + composed, + crate::tr!("modal", "plot"), + plot_content, + Message::CloseModal, + *plot_offset, + crate::ui::modal::ModalOptions::STANDARD, + ) } else { composed.into() - }; + } + } else { + composed.into() + }; // ── In-canvas modal dialogs (Plan B) ─────────────────────────────── // Former pop-up windows render as overlays here, so they work on both @@ -2389,10 +2354,8 @@ bg={bg_ms:.1}ms n={view_count}" // is preceded by the preamble, reported as `head`. let mut detail = String::new(); if let Some((_, first)) = marks.first() { - let _ = std::fmt::Write::write_fmt( - &mut detail, - format_args!(" head={first:.1}"), - ); + let _ = + std::fmt::Write::write_fmt(&mut detail, format_args!(" head={first:.1}")); } for pair in marks.windows(2) { let _ = std::fmt::Write::write_fmt( @@ -2407,10 +2370,8 @@ bg={bg_ms:.1}ms n={view_count}" if let Some((_, current)) = self.color_pick_target.as_ref() { match self.color_picker_tab { super::ColorPickerTab::Index => { - let content = crate::ui::color_select::index_color_page( - *current, - &self.recent_colors, - ); + let content = + crate::ui::color_select::index_color_page(*current, &self.recent_colors); crate::ui::modal::modal( base, @@ -2424,22 +2385,19 @@ bg={bg_ms:.1}ms n={view_count}" super::ColorPickerTab::TrueColor => { let initial = crate::ui::properties::acad_color_display(*current).0; - let modal_base = - crate::ui::modal::backdrop(base, Message::CloseColorPicker); + let modal_base = crate::ui::modal::backdrop(base, Message::CloseColorPicker); iced_aw::ColorPicker::new( true, initial, modal_base, - // Cancel inside True Color returns to the indexed page instead // of closing the whole CAD colour picker. Message::ColorPickerTabChanged(super::ColorPickerTab::Index), - |color| { - Message::ColorWindowPick( - crate::ui::color_select::iced_to_acad_color(color), - ) + Message::ColorWindowPick(crate::ui::color_select::iced_to_acad_color( + color, + )) }, ) .into() @@ -2555,8 +2513,10 @@ impl OpenCADStudio { // the interval in minutes; 0 disables it. #[cfg(not(target_arch = "wasm32"))] let autosave = if self.savetime_min > 0 { - iced::time::every(std::time::Duration::from_secs(self.savetime_min as u64 * 60)) - .map(|_| Message::AutoSave) + iced::time::every(std::time::Duration::from_secs( + self.savetime_min as u64 * 60, + )) + .map(|_| Message::AutoSave) } else { Subscription::none() }; @@ -2728,7 +2688,8 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] let control = super::control::subscribe().map(Message::ControlRequest); #[cfg(target_arch = "wasm32")] - let control = iced::time::every(std::time::Duration::from_millis(50)).map(|_|Message::PollWebControl); + let control = iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::PollWebControl); iced::Subscription::batch([ control, frames, @@ -2754,9 +2715,7 @@ impl OpenCADStudio { } pub(super) fn unfocus_widgets(&self) -> Task { - iced::advanced::widget::operate( - iced::advanced::widget::operation::focusable::unfocus(), - ) + iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } /// Build one edge: a narrow tab strip at the very edge plus the expanded @@ -2907,9 +2866,7 @@ impl OpenCADStudio { ) -> Element<'a, Message> { let auto_collapse = self.dock.auto_collapse(id); let panel: Element<'_, Message> = match id { - crate::ui::dock::PanelId::Properties => { - tab.properties.view(width, auto_collapse) - } + crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } @@ -2983,10 +2940,10 @@ fn doc_tab_context_menu( } container(menu.spacing(0).width(MENU_W)) - .style(container::bordered_box) - .padding([4, 0]) - .width(iced::Length::Fixed(MENU_W)) - .into() + .style(container::bordered_box) + .padding([4, 0]) + .width(iced::Length::Fixed(MENU_W)) + .into() } pub(super) fn doc_tab_bar<'a>( @@ -3043,12 +3000,7 @@ pub(super) fn doc_tab_bar<'a>( let title_btn: Element<'_, Message> = if tab.is_start { title_btn.into() } else { - crate::ui::wrap_bar::ReorderTab::document( - idx, - drag_targets.clone(), - title_btn, - ) - .into() + crate::ui::wrap_bar::ReorderTab::document(idx, drag_targets.clone(), title_btn).into() }; // Start tab is fixed — no close button. Every other tab gets a close. @@ -3156,10 +3108,7 @@ pub(super) fn doc_tab_bar<'a>( .padding([4, 8]) .style(|theme: &Theme, status| { let palette = theme.palette(); - let hovered = matches!( - status, - button::Status::Hovered | button::Status::Pressed - ); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); button::Style { background: Some(Background::Color(if hovered { palette.background.weak.color @@ -3195,20 +3144,18 @@ pub(super) fn doc_tab_bar<'a>( .wrap() .vertical_spacing(2.0), ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color, - )), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .width(Fill) - .padding([2, 2]) - .into() + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .width(Fill) + .padding([2, 2]) + .into() } // ── Layout context-menu overlay ──────────────────────────────────────────── @@ -3308,9 +3255,7 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { .width(Length::Fixed(5.0)) .height(Fill) .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), + background: Some(Background::Color(theme.palette().background.neutral.color)), ..Default::default() }); let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); @@ -3337,10 +3282,7 @@ pub(super) fn start_page_view<'a>( discussions: &'a [crate::discussions::DiscussionEntry], discussions_loading: bool, recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, recent_limit: usize, recent_limit_input: &'a str, action_width_out: std::sync::Arc, @@ -3374,10 +3316,7 @@ fn start_page_content<'a>( discussions: &'a [crate::discussions::DiscussionEntry], discussions_loading: bool, recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, recent_limit: usize, recent_limit_input: &'a str, avail_w: f32, @@ -3449,14 +3388,15 @@ fn start_page_content<'a>( .into(), outline_btn(crate::tr!("action", "options"), Message::OptionsOpen).into(), ]; - secondary_items.push(outline_btn(crate::tr!("action", "plugins"), Message::PluginManagerOpen).into()); + secondary_items + .push(outline_btn(crate::tr!("action", "plugins"), Message::PluginManagerOpen).into()); // The web build is already in the browser, so only the desktop offers a // link to the web version. #[cfg(not(target_arch = "wasm32"))] { // Filled with the active theme's primary colour. secondary_items.push( - button(text(crate::t!("OCS Web")).size(14)) + button(text(crate::t!("OCS Web")).size(14)) .on_press(Message::RibbonToolClick { tool_id: "WEBVERSION".to_string(), event: crate::modules::ModuleEvent::Command("WEBVERSION".to_string()), @@ -3468,7 +3408,7 @@ fn start_page_content<'a>( } #[cfg(target_arch = "wasm32")] secondary_items.push( - button(text(crate::t!("OCS Desktop")).size(14)) + button(text(crate::t!("OCS Desktop")).size(14)) .on_press(Message::OpenUrl( "https://github.com/HakanSeven12/OpenCADStudio/releases/latest".to_string(), )) @@ -3533,8 +3473,8 @@ fn start_page_content<'a>( mouse_area( container( iced::widget::image(MOBILE_SPONSOR_IMAGE.clone()) - .width(Fill) - .content_fit(iced::ContentFit::Contain), + .width(Fill) + .content_fit(iced::ContentFit::Contain), ) .width(Fill), ) @@ -3577,9 +3517,8 @@ fn start_page_content<'a>( let panel_w = 280.0f32; const VIDEO_PANEL_PADDING: f32 = 16.0; const VIDEO_SCROLL_GUTTER: f32 = 14.0; - let measured_action_w = f32::from_bits( - action_width_out.load(std::sync::atomic::Ordering::Relaxed) - ); + let measured_action_w = + f32::from_bits(action_width_out.load(std::sync::atomic::Ordering::Relaxed)); let welcome_wide_min = measured_action_w.max(360.0); let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding let panel_widths = [panel_w; 4]; @@ -3630,8 +3569,7 @@ fn start_page_content<'a>( let videos_panel: Element<'a, Message> = { // Derive the 16:9 cover box from the actual shared list width so the // whole thumbnail remains visible when that width changes. - let thumb_h = - (panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0; + let thumb_h = (panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0; let mut list = column![text(crate::tr!("start", "tutorials")).size(15)] .spacing(10) .width(Fill) @@ -3680,22 +3618,22 @@ fn start_page_content<'a>( } let playlist_btn = mouse_area( container(text(crate::tr!("start", "open-playlist")).size(12)) - .padding([6, 10]) - .width(Fill) - .center_x(Fill) - .style(|theme: &Theme| { - let pair = theme.palette().danger.base; - container::Style { - background: Some(Background::Color(pair.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - text_color: Some(pair.text), - ..Default::default() - } - }), + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|theme: &Theme| { + let pair = theme.palette().danger.base; + container::Style { + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() + } + }), ) .interaction(iced::mouse::Interaction::Pointer) .on_press(Message::OpenUrl(crate::videos::PLAYLIST_URL.to_string())); @@ -3716,13 +3654,13 @@ fn start_page_content<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() @@ -3736,11 +3674,9 @@ fn start_page_content<'a>( .spacing(8) .width(Fill); for discussion in discussions { - let mut meta = iced::widget::row![ - text(format!("#{}", discussion.number)) - .size(10) - .style(start_muted_style), - ] + let mut meta = iced::widget::row![text(format!("#{}", discussion.number)) + .size(10) + .style(start_muted_style),] .spacing(6) .align_y(iced::Center); if discussion.pinned { @@ -3757,29 +3693,24 @@ fn start_page_content<'a>( .style(start_muted_style), ); } - let card = container( - column![ - text(discussion.title.clone()).size(12), - meta, - ] - .spacing(4), - ) - .padding([8, 10]) - .width(Fill) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color( - palette.background.base.color.scale_alpha(0.42), - )), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 6.0.into(), - }, - ..Default::default() - } - }); + let card = + container(column![text(discussion.title.clone()).size(12), meta,].spacing(4)) + .padding([8, 10]) + .width(Fill) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.42), + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); list = list.push( mouse_area(card) .interaction(iced::mouse::Interaction::Pointer) @@ -3823,9 +3754,7 @@ fn start_page_content<'a>( open_btn, ]) .width(match start_layout { - StartLayout::AllPanels | StartLayout::WithoutVideos => { - iced::Length::Fixed(panel_w) - } + StartLayout::AllPanels | StartLayout::WithoutVideos => iced::Length::Fixed(panel_w), StartLayout::WithoutVideosAndDiscussions | StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, @@ -3888,14 +3817,14 @@ fn start_page_content<'a>( .style(|theme: &Theme| { let pair = theme.palette().danger.base; container::Style { - background: Some(Background::Color(pair.color)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - text_color: Some(pair.text), - ..Default::default() + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() } }), ) @@ -3911,52 +3840,43 @@ fn start_page_content<'a>( .width(match start_layout { StartLayout::AllPanels | StartLayout::WithoutVideos - | StartLayout::WithoutVideosAndDiscussions => { - iced::Length::Fixed(panel_w) - } - StartLayout::RecentAndWelcome - | StartLayout::Compact => iced::Length::Fill, + | StartLayout::WithoutVideosAndDiscussions => iced::Length::Fixed(panel_w), + StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, }) .height(Fill) .padding(20) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() }; let body: Element<'a, Message> = match start_layout { - StartLayout::AllPanels => iced::widget::row![ - recent, - videos_panel, - welcome, - discussions_panel, - supporters, - ] - .spacing(16) - .height(Fill) - .into(), + StartLayout::AllPanels => { + iced::widget::row![recent, videos_panel, welcome, discussions_panel, supporters,] + .spacing(16) + .height(Fill) + .into() + } StartLayout::WithoutVideos => { iced::widget::row![recent, welcome, discussions_panel, supporters] .spacing(16) .height(Fill) .into() } - StartLayout::WithoutVideosAndDiscussions => { - iced::widget::row![recent, welcome, supporters] - .spacing(16) - .height(Fill) - .into() - } + StartLayout::WithoutVideosAndDiscussions => iced::widget::row![recent, welcome, supporters] + .spacing(16) + .height(Fill) + .into(), StartLayout::RecentAndWelcome => iced::widget::row![recent, welcome] .spacing(16) .height(Fill) @@ -3971,35 +3891,45 @@ fn start_page_content<'a>( let palette = theme.palette(); let pair = match (is_active, status) { (true, _) => Some(palette.primary.weak), - (false, button::Status::Hovered) => { - Some(palette.background.strong) - } + (false, button::Status::Hovered) => Some(palette.background.strong), _ => None, }; button::Style { - background: pair.map(|p| Background::Color(p.color)), - text_color: pair - .map(|p| p.text) - .unwrap_or(palette.background.base.text.scale_alpha(0.68)), - border: Border { - color: if is_active { - palette.primary.base.color - } else { - Color::TRANSPARENT + background: pair.map(|p| Background::Color(p.color)), + text_color: pair + .map(|p| p.text) + .unwrap_or(palette.background.base.text.scale_alpha(0.68)), + border: Border { + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 6.0.into(), }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 6.0.into(), - }, - ..Default::default() + ..Default::default() } }) }; let tab_bar = Row::with_children(vec![ - tab_btn(crate::tr!("start", "recent-files"), super::StartSection::Recent).into(), + tab_btn( + crate::tr!("start", "recent-files"), + super::StartSection::Recent, + ) + .into(), tab_btn(crate::tr!("start", "videos"), super::StartSection::Videos).into(), tab_btn(crate::tr!("start", "welcome"), super::StartSection::Welcome).into(), - tab_btn(crate::tr!("start", "discussions"), super::StartSection::Discussions).into(), - tab_btn(crate::tr!("start", "supporters"), super::StartSection::Supporters).into(), + tab_btn( + crate::tr!("start", "discussions"), + super::StartSection::Discussions, + ) + .into(), + tab_btn( + crate::tr!("start", "supporters"), + super::StartSection::Supporters, + ) + .into(), ]) .spacing(6.0) .align_y(iced::Center) @@ -4040,21 +3970,19 @@ fn start_page_content<'a>( }; container(body) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color - )), - ..Default::default() - }) - .padding(iced::Padding { - top: 16.0, - right: 8.0, - bottom: 16.0, - left: 8.0, - }) - .width(Fill) - .height(Fill) - .into() + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .padding(iced::Padding { + top: 16.0, + right: 8.0, + bottom: 16.0, + left: 8.0, + }) + .width(Fill) + .height(Fill) + .into() } // ── Recent Documents panel (Start tab left rail) ────────────────────────── @@ -4065,10 +3993,7 @@ fn start_page_content<'a>( // open — entries persist across sessions. pub(super) fn recent_files_panel<'a>( recents: &'a [std::path::PathBuf], - thumbs: &'a std::collections::HashMap< - std::path::PathBuf, - Option, - >, + thumbs: &'a std::collections::HashMap>, limit: usize, limit_input: &'a str, width: iced::Length, @@ -4081,10 +4006,10 @@ pub(super) fn recent_files_panel<'a>( container( text(crate::tr!("start", "no-recent-files")) .size(12) - .style(start_muted_style) + .style(start_muted_style), ) - .height(Fill) - .into() + .height(Fill) + .into() } else { // Right padding reserves a gutter for the scrollbar so it doesn't sit on // top of the row's ✕ remove button. @@ -4132,8 +4057,7 @@ pub(super) fn recent_files_panel<'a>( row![ thumb, column![ - text(crate::ui::text_util::elide(&name, 28)) - .size(12), + text(crate::ui::text_util::elide(&name, 28)).size(12), text(crate::ui::text_util::elide(&dir, 38)) .size(10) .style(start_muted_style), @@ -4149,16 +4073,15 @@ pub(super) fn recent_files_panel<'a>( .style(move |theme: &Theme, status| { let palette = theme.palette(); button::Style { - background: matches!(status, button::Status::Hovered).then_some( - Background::Color(palette.background.strong.color) - ), - text_color: palette.background.base.text, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 0.0.into(), - }, - ..Default::default() + background: matches!(status, button::Status::Hovered) + .then_some(Background::Color(palette.background.strong.color)), + text_color: palette.background.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() } }); @@ -4167,11 +4090,11 @@ pub(super) fn recent_files_panel<'a>( crate::ui::icons::CLOSE, 11.0, )) - .on_press(Message::RecentRemove(path_for_remove)) - .padding([4, 8]) - .style(|theme: &Theme, status| { - let palette = theme.palette(); - button::Style { + .on_press(Message::RecentRemove(path_for_remove)) + .padding([4, 8]) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { background: matches!(status, button::Status::Hovered) .then_some(Background::Color(palette.danger.weak.color)), text_color: palette.background.base.text.scale_alpha(0.68), @@ -4181,8 +4104,8 @@ pub(super) fn recent_files_panel<'a>( radius: 3.0.into(), }, ..Default::default() - } - }); + } + }); col = col.push(row![open_btn, remove_btn].spacing(0).align_y(iced::Center)); } @@ -4197,16 +4120,15 @@ pub(super) fn recent_files_panel<'a>( let step_style = |theme: &Theme, status: button::Status| { let palette = theme.palette(); button::Style { - background: matches!(status, button::Status::Hovered).then_some( - Background::Color(palette.background.strong.color) - ), - text_color: palette.background.base.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + background: matches!(status, button::Status::Hovered) + .then_some(Background::Color(palette.background.strong.color)), + text_color: palette.background.base.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() } }; // +/- step from whatever is currently shown in the box (mid-edit included). @@ -4218,7 +4140,10 @@ pub(super) fn recent_files_panel<'a>( .padding([2, 6]) .width(iced::Length::Fixed(46.0)); let limit_row = row![ - text(crate::tr!("start", "keep-recent-files")).size(11).style(start_muted_style).width(Fill), + text(crate::tr!("start", "keep-recent-files")) + .size(11) + .style(start_muted_style) + .width(Fill), button(crate::ui::icons::themed(crate::ui::icons::MINUS, 11.0)) .on_press(Message::SetRecentLimit(shown.saturating_sub(STEP))) .padding([3, 6]) @@ -4251,13 +4176,13 @@ pub(super) fn recent_files_panel<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 8.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() } }) .into() diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index ba6a6db69..218fe3e4d 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -201,7 +201,6 @@ impl OpenCADStudio { crate::ui::window::options::view_window( &self.default_save_format, self.file_assoc_enabled, - self.write_dwg_native_constraints, self.show_constraint_values, &self.ui_theme, &self.theme_color_inputs, diff --git a/src/command.rs b/src/command.rs index e5b43ea76..b4e9da1df 100644 --- a/src/command.rs +++ b/src/command.rs @@ -25,10 +25,15 @@ pub enum HatchEditOperation { style: Option, annotative: Option, }, - RecreateBoundary { associate: bool, region: bool }, + RecreateBoundary { + associate: bool, + region: bool, + }, BeginAssociate, AssociatePaths(Vec), - DrawOrderBoundary { above: bool }, + DrawOrderBoundary { + above: bool, + }, Separate, AddBoundaries(Vec), RemoveBoundaries(Vec), @@ -102,8 +107,7 @@ impl WorkingPlane { pub fn angle(self, from: DVec3, to: DVec3) -> Option { let direction = self.vector_to_local(to - from); - (direction.x.hypot(direction.y) > f64::EPSILON) - .then(|| direction.y.atan2(direction.x)) + (direction.x.hypot(direction.y) > f64::EPSILON).then(|| direction.y.atan2(direction.x)) } pub fn to_world_transform(self) -> acadrust::types::Transform { @@ -493,16 +497,18 @@ impl CadCommand for UserRegCommand { fn prompt(&self) -> String { match self.slot { - None => crate::t!("%{name} which register? [1-5]:", name = self.name) - .into_owned(), - Some(n) => crate::t!("%{name}%{slot} new value:", name = self.name, slot = n) - .into_owned(), + None => crate::t!("%{name} which register? [1-5]:", name = self.name).into_owned(), + Some(n) => { + crate::t!("%{name}%{slot} new value:", name = self.name, slot = n).into_owned() + } } } fn options(&self) -> Vec { match self.slot { - None => (1..=5).map(|n| CmdOption::new(&n.to_string(), &n.to_string())).collect(), + None => (1..=5) + .map(|n| CmdOption::new(&n.to_string(), &n.to_string())) + .collect(), Some(_) => Vec::new(), } } @@ -593,9 +599,10 @@ fn match_cmd_option<'a>( return None; } // 1. Exact match on keyword or label (case-insensitive) - if let Some(opt) = options.iter().find(|(label, k, _)| { - k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) - }) { + if let Some(opt) = options + .iter() + .find(|(label, k, _)| k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)) + { return Some(opt); } // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") @@ -650,8 +657,7 @@ impl CadCommand for KeywordCommand { // Consumed inputs that keep prompting return `Some(NeedPoint)` — // `None` would hand the same text to the command a second time. None => { - let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) - else { + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); }; @@ -809,7 +815,7 @@ impl CadCommand for Mid2PointCommand { WireModel::CYAN, false, )); - // Midpoint marker (AutoCAD style triangle glyph) + // Midpoint marker (triangle glyph) let mid = (first + pt) * 0.5; let dist = (pt - first).length(); let s = (dist * 0.02).clamp(0.5, 10.0); @@ -834,7 +840,6 @@ inventory::submit!(CommandRegistration { names: &["MTP", "M2P"], }); - /// Generic interactive front-end for a keyword command that operates on the /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// is selected when it starts it first gathers a selection (Enter confirms), @@ -942,8 +947,7 @@ impl CadCommand for SelectThenKeywordCommand { } } None => { - let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) - else { + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — consumed, keep prompting (`None` would // feed the same text to the command a second time). return Some(CmdResult::NeedPoint); @@ -1405,7 +1409,10 @@ pub enum CmdResult { /// Copy selected entities with a transform; command stays active for more copies. CopySelected(Vec, EntityTransform), /// Store selected entities in the shared clipboard; command stays active. - CopyToClipboard { handles: Vec, base: DVec3 }, + CopyToClipboard { + handles: Vec, + base: DVec3, + }, /// Commit a hatch fill (stored in Scene::hatches, not the DXF document). CommitHatch(HatchModel), /// Commit a hatch with the selected hatch's entity colour and transparency. @@ -1430,22 +1437,25 @@ pub enum CmdResult { /// Erase `handle` and replace with new entities; command stays active. ReplaceEntity(Handle, Vec), /// Update one entity in place, preserve its handle, and end the command. - UpdateEntityAndFinish { handle: Handle, entity: EntityType }, + UpdateEntityAndFinish { + handle: Handle, + entity: EntityType, + }, /// Replace / delete multiple entities and add new ones; command ends. /// Each pair: (handle_to_erase, replacement_entities) — empty vec = delete only. ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Replace several entities as one undo step while keeping the command active. ReplaceManyContinue(Vec<(Handle, Vec)>), /// Add a persistent parametric constraint to the current scope's - /// `SketchConstraintSet` and trigger its + /// `ParametricConstraintSet` and trigger its /// first solve; end the command. Unlike `ReplaceMany`/`CommitEntity`, /// nothing here is geometry to add or replace directly — the host adds /// the constraint record, then re-solves through `Scene::bump_entities`'s - /// existing chain (`refresh_sketch_constraints`) the same way any later + /// existing chain (`refresh_parametric_constraints`) the same way any later /// edit to these entities will. - AddSketchConstraint { - kind: crate::scene::sketch_constraints::ConstraintKind, - refs: Vec, + AddParametricConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, + refs: Vec, /// The typed target for a dimensional kind (Distance/Angle/Radius) — /// a literal number, or a named-parameter reference recognized by /// `DistanceConstraintCommand`/`AngleConstraintCommand:: @@ -1468,7 +1478,7 @@ pub enum CmdResult { /// (`crate::modules::parametric::point_on_entity`) between one /// picked point and `target`, a whole entity selected before the tool /// ran. Like `AddCoincidentConstraint`, the host resolves `point` via - /// `sketch_constraints::nearest_sketch_point` (a `CadCommand` has no + /// `parametric_constraints::nearest_parametric_point` (a `CadCommand` has no /// document access) and, for `CenterPoint` specifically, addresses /// `target` via its center marker rather than as a whole entity — see /// `ConstraintKind::CenterPoint`'s own doc comment for why it's the @@ -1477,14 +1487,14 @@ pub enum CmdResult { AddPointOnEntityConstraint { point: DVec3, target: Handle, - kind: crate::scene::sketch_constraints::ConstraintKind, + kind: crate::scene::parametric_constraints::ConstraintKind, label: &'static str, }, /// Add a persistent `EqualDistance` constraint /// (`crate::modules::parametric::equal_distance`): the distance /// between `points[0]`/`points[1]` equals the distance between /// `points[2]`/`points[3]`. The host resolves each point via - /// `sketch_constraints::nearest_sketch_point`, same reasoning as + /// `parametric_constraints::nearest_parametric_point`, same reasoning as /// `AddCoincidentConstraint`. AddEqualDistanceConstraint { points: [DVec3; 4], @@ -1516,7 +1526,10 @@ pub enum CmdResult { /// it owns the document needed for the table-style lookup — honors /// content locks, and launches the cell editor, re-prompting the /// command when the pick misses a cell. - EditTableCell { handle: Handle, point: DVec3 }, + EditTableCell { + handle: Handle, + point: DVec3, + }, /// Cancel because the active drawing space changed. Cleanup is identical /// to `Cancel`, but the host reports the context change explicitly. CancelForSpaceChange, @@ -1539,13 +1552,24 @@ pub enum CmdResult { /// `Relaunch` it does not touch the selection. Dispatch(String), /// Move `dest` entities to the layer of the `src` entity; end command. - MatchEntityLayer { dest: Vec, src: Handle }, + MatchEntityLayer { + dest: Vec, + src: Handle, + }, /// Copy all visual properties (layer/color/linetype/lineweight) from `src` to `dest`; end command. - MatchProperties { dest: Vec, src: Handle }, + MatchProperties { + dest: Vec, + src: Handle, + }, /// Create a named group from the given entity handles; end command. - CreateGroup { handles: Vec, name: String }, + CreateGroup { + handles: Vec, + name: String, + }, /// Dissolve all groups that contain any of the given handles; end command. - DeleteGroups { handles: Vec }, + DeleteGroups { + handles: Vec, + }, /// Freeze or thaw layers by name in the given viewport; command stays active. VpLayerUpdate { vp_handle: Handle, @@ -1553,9 +1577,14 @@ pub enum CmdResult { thaw: Vec, }, /// Paste clipboard entities translated so their centroid lands at `base_pt`; end command. - PasteClipboard { base_pt: DVec3 }, + PasteClipboard { + base_pt: DVec3, + }, /// Zoom the model-space camera to fit the given corner points; end command. - ZoomToWindow { p1: DVec3, p2: DVec3 }, + ZoomToWindow { + p1: DVec3, + p2: DVec3, + }, /// Print a measurement result to the command line and end the command. Measurement(String), /// Print a measurement result and keep the command active. @@ -1567,20 +1596,36 @@ pub enum CmdResult { /// Clear the current selection and keep the command active at its updated step. DeselectAndContinue, /// Break `handle` at points `p1` and `p2`; replace with computed fragments. - BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, + BreakEntity { + handle: Handle, + p1: DVec3, + p2: DVec3, + }, /// Attempt to join the given entities into fewer merged entities. JoinEntities(Vec), /// Join candidates into an explicitly selected source. - JoinToSource { source: Handle, handles: Vec }, + JoinToSource { + source: Handle, + handles: Vec, + }, /// Apply a polyline-edit operation to one entity; keep command active. PeditOp { handle: Handle, op: crate::modules::draw::modify::pedit::PeditOp, }, /// Place Point entities at N equal intervals along the entity. - DivideEntity { handle: Handle, n: usize, marker: Option }, + DivideEntity { + handle: Handle, + n: usize, + marker: Option, + }, /// Place Point entities at `segment_length` intervals along the entity. - MeasureEntity { handle: Handle, segment_length: f64, pick_point: DVec3, marker: Option }, + MeasureEntity { + handle: Handle, + segment_length: f64, + pick_point: DVec3, + marker: Option, + }, /// Extend/trim a Line or Arc by the given mode; end command. LengthenEntity { handle: Handle, @@ -1596,7 +1641,10 @@ pub enum CmdResult { scale: f64, }, /// Set the plot window on the active layout's PlotSettings. - SetPlotWindow { p1: DVec3, p2: DVec3 }, + SetPlotWindow { + p1: DVec3, + p2: DVec3, + }, /// Create a paper-space viewport. `preserve_view` keeps an explicitly /// selected/defined view instead of applying the normal model-extents fit. MviewCreate { @@ -1623,7 +1671,10 @@ pub enum CmdResult { /// Quick-print the bounding box of the given selected entities to a PDF. QuickPrint(Vec), /// Replace the text content of a Text/MText entity in-place. - DdeditEntity { handle: Handle, new_text: String }, + DdeditEntity { + handle: Handle, + new_text: String, + }, /// Apply one DIMEDIT operation to every selected dimension. EditDimensions { handles: Vec, @@ -1631,7 +1682,9 @@ pub enum CmdResult { }, /// Open the in-place editor (plain box or rich MText editor, per type) for /// a text-bearing entity picked by a command such as DDEDIT. - EditTextEntity { handle: Handle }, + EditTextEntity { + handle: Handle, + }, /// Open the in-place MText editor (formatting toolbar + multi-line text /// area with live viewport preview). `handle` is `Some` when editing an /// existing MText, `None` when creating a new one at `pos`. @@ -1784,7 +1837,9 @@ pub enum CmdResult { /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. - AttreqNeeded { block_name: String }, + AttreqNeeded { + block_name: String, + }, /// Add a command-owned "live" entity to the document mid-command and hand /// its assigned handle back to the active command via `set_live_handle()`. /// One undo snapshot is pushed here, so the whole in-progress object reverts @@ -1808,7 +1863,9 @@ pub enum CmdResult { /// PLINE's Undo popping back below the two vertices an entity needs. RemoveLiveEntity(Handle), /// Suspends command execution, moves it to suspended_cmd, and opens the text editor for the given handle. - SuspendForTextEdit { handle: Handle }, + SuspendForTextEdit { + handle: Handle, + }, /// Requests a standard document-level undo while keeping the command active. UndoDocument, /// Sets the TEXTEDITMODE system variable and ends the command. @@ -2048,7 +2105,6 @@ pub struct CurveMarker { pub plane: WorkingPlane, } - /// Current screen projection and configured aperture for point-feature picking. #[derive(Clone, Copy)] pub struct PointPickContext { @@ -2060,10 +2116,16 @@ pub struct PointPickContext { pub trait CadCommand: Send { /// Preserve source appearance for commands that extract existing entities. - fn preserve_commit_style(&self) -> bool { false } - fn nested_copy_bind_setting(&self) -> Option { None } + fn preserve_commit_style(&self) -> bool { + false + } + fn nested_copy_bind_setting(&self) -> Option { + None + } /// Symbol localization is committed together with the extracted entities. - fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { None } + fn nested_copy_symbol_names(&self) -> Option<&acadrust::nested_copy::NestedCopySymbolNames> { + None + } /// Keep the layer already carried by entities committed by this command /// instead of replacing it with the current drawing layer. fn preserve_commit_layer(&self) -> bool { @@ -2131,7 +2193,9 @@ pub trait CadCommand: Send { fn on_point(&mut self, pt: DVec3) -> CmdResult; /// Opt in only while a point input selects an existing point feature. - fn wants_point_pick_context(&self) -> bool { false } + fn wants_point_pick_context(&self) -> bool { + false + } /// Refreshed for each point input so zoom and viewport changes are reflected. fn set_point_pick_context(&mut self, _context: Option) {} @@ -2203,7 +2267,10 @@ pub trait CadCommand: Send { } fn on_deferred_entity_hover( - &mut self, _scene: &Scene, _handle: Option, _point: DVec3, + &mut self, + _scene: &Scene, + _handle: Option, + _point: DVec3, ) -> Vec { Vec::new() } @@ -2417,7 +2484,9 @@ pub trait CadCommand: Send { } /// Exclude locked-layer entities from injected selection geometry. - fn selection_entities_exclude_locked(&self) -> bool { false } + fn selection_entities_exclude_locked(&self) -> bool { + false + } fn inject_selection_entities(&mut self, _entities: Vec) {} @@ -2425,9 +2494,7 @@ pub trait CadCommand: Send { None } - fn hatch_preview_models( - &self, - ) -> Option> { + fn hatch_preview_models(&self) -> Option> { None } @@ -2632,7 +2699,10 @@ mod constraint_registry_tests { "DCONSTRAINT", "ACONSTRAINT", ] { - assert!(names.contains(&id), "{id} is missing from the command registry"); + assert!( + names.contains(&id), + "{id} is missing from the command registry" + ); } } } diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index eccad85ba..356c6b556 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -8,7 +8,7 @@ //! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this //! command only accumulates the two raw points `on_point` receives and hands //! them back as `CmdResult::AddCoincidentConstraint` — the host resolves -//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`) +//! each point to a real `ParametricRef` (`parametric_constraints::nearest_parametric_point`) //! and adds the constraint, the same split `ReassociateCenterMark` already //! uses for a similar reason. diff --git a/src/modules/parametric/point_on_entity.rs b/src/modules/parametric/point_on_entity.rs index 1ce9ac91e..4bf7cd0a3 100644 --- a/src/modules/parametric/point_on_entity.rs +++ b/src/modules/parametric/point_on_entity.rs @@ -7,7 +7,7 @@ //! endpoint) so it doesn't need interactive picking; the point side does //! (which of a line's two endpoints, or some other addressable point), so //! this command accumulates just that one point pick and hands it back for -//! the host to resolve via `nearest_sketch_point` — the same split +//! the host to resolve via `nearest_parametric_point` — the same split //! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand` //! has no document access). @@ -15,7 +15,7 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::sketch_constraints::ConstraintKind; +use crate::scene::parametric_constraints::ConstraintKind; use acadrust::types::Handle; pub mod center_point_tool { diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 32b886b3c..9a0a57293 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -64,9 +64,7 @@ pub mod equal { ToolDef { id: "ECONSTRAINT", label: "Equal", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/equal.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/equal.svg")), event: ModuleEvent::Command("ECONSTRAINT".to_string()), } } @@ -105,7 +103,7 @@ pub mod concentric { /// "the line passes through the circle's center" (a circle's radius is /// always normal to its own tangent), so it's built from the same /// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line -/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only +/// case (`parametric_solve.rs`). Distinct from `Perpendicular`, which only /// covers line-to-line. pub mod normal { use super::*; @@ -113,9 +111,7 @@ pub mod normal { ToolDef { id: "NRCONSTRAINT", label: "Normal", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/normal.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/normal.svg")), event: ModuleEvent::Command("NRCONSTRAINT".to_string()), } } @@ -141,9 +137,7 @@ pub mod fixed { ToolDef { id: "FXCONSTRAINT", label: "Fixed", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/fixed.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } } diff --git a/src/modules/parametric/value.rs b/src/modules/parametric/value.rs index 068923701..de84df66a 100644 --- a/src/modules/parametric/value.rs +++ b/src/modules/parametric/value.rs @@ -4,8 +4,8 @@ //! //! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only //! gets `&mut self` — no document access — so it can't add a -//! `SketchConstraint` to the scene directly; instead these commands hand the -//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which +//! `ParametricConstraint` to the scene directly; instead these commands hand the +//! typed value back as `CmdResult::AddParametricConstraint`, and the host (which //! does have `&mut Scene`) adds the record and solves it via the same //! `Scene::bump_entities` path any later edit to these entities will use too. //! `default_value` (what the prompt shows in `<...>`, and what a bare Enter @@ -19,7 +19,7 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult, InputKind}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::named_parameters::DrivingValue; -use crate::scene::sketch_constraints::{ConstraintKind, SketchRef}; +use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; use crate::scene::Scene; /// Recognizes a typed prompt token as either a numeric literal or a @@ -28,7 +28,7 @@ use crate::scene::Scene; /// ::new`, which already has `&Scene`) since `on_text_input` itself has no /// document access — the same constraint `default_value` already works /// around. Only *existence* is checked here; the actual value is resolved -/// fresh at solve time (`sketch_solve::build_constraint`), consistent with +/// fresh at solve time (`parametric_solve::build_constraint`), consistent with /// this project's "no incremental/cached resolution" approach throughout. fn parse_driving_value(text: &str, known_names: &[String]) -> Option { let text = text.trim(); @@ -64,17 +64,70 @@ pub mod dimensional_tools { use super::*; fn command(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { - ToolDef { id, label, icon: IconKind::Svg(icon), event: ModuleEvent::Command(id.to_string()) } + ToolDef { + id, + label, + icon: IconKind::Svg(icon), + event: ModuleEvent::Command(id.to_string()), + } } - pub fn linear() -> ToolDef { command("DCLINEAR", "Linear", include_bytes!("../../../assets/icons/dim_linear.svg")) } - pub fn horizontal() -> ToolDef { command("DCHORIZONTAL", "Horizontal", include_bytes!("../../../assets/icons/constrain/distance_x.svg")) } - pub fn vertical() -> ToolDef { command("DCVERTICAL", "Vertical", include_bytes!("../../../assets/icons/constrain/distance_y.svg")) } - pub fn aligned() -> ToolDef { command("DCALIGNED", "Aligned", include_bytes!("../../../assets/icons/dim_aligned.svg")) } - pub fn angular() -> ToolDef { command("DCANGULAR", "Angular", include_bytes!("../../../assets/icons/dim_angular.svg")) } - pub fn radius() -> ToolDef { command("DCRADIUS", "Radius", include_bytes!("../../../assets/icons/dim_radius.svg")) } - pub fn diameter() -> ToolDef { command("DCDIAMETER", "Diameter", include_bytes!("../../../assets/icons/dim_diameter.svg")) } - pub fn convert() -> ToolDef { command("DCCONVERT", "Convert", include_bytes!("../../../assets/icons/constrain/convert.svg")) } + pub fn linear() -> ToolDef { + command( + "DCLINEAR", + "Linear", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ) + } + pub fn horizontal() -> ToolDef { + command( + "DCHORIZONTAL", + "Horizontal", + include_bytes!("../../../assets/icons/constrain/distance_x.svg"), + ) + } + pub fn vertical() -> ToolDef { + command( + "DCVERTICAL", + "Vertical", + include_bytes!("../../../assets/icons/constrain/distance_y.svg"), + ) + } + pub fn aligned() -> ToolDef { + command( + "DCALIGNED", + "Aligned", + include_bytes!("../../../assets/icons/dim_aligned.svg"), + ) + } + pub fn angular() -> ToolDef { + command( + "DCANGULAR", + "Angular", + include_bytes!("../../../assets/icons/dim_angular.svg"), + ) + } + pub fn radius() -> ToolDef { + command( + "DCRADIUS", + "Radius", + include_bytes!("../../../assets/icons/dim_radius.svg"), + ) + } + pub fn diameter() -> ToolDef { + command( + "DCDIAMETER", + "Diameter", + include_bytes!("../../../assets/icons/dim_diameter.svg"), + ) + } + pub fn convert() -> ToolDef { + command( + "DCCONVERT", + "Convert", + include_bytes!("../../../assets/icons/constrain/convert.svg"), + ) + } } pub mod angle_tool { @@ -83,9 +136,7 @@ pub mod angle_tool { ToolDef { id: "ACONSTRAINT", label: "Angle", - icon: IconKind::Svg(include_bytes!( - "../../../assets/icons/constrain/angle.svg" - )), + icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/angle.svg")), event: ModuleEvent::Command("ACONSTRAINT".to_string()), } } @@ -104,14 +155,10 @@ pub enum DistanceMode { X, Y, Diameter, - /// Arc length — only offered when `is_arc` (not a plain Circle, which - /// has no "arc length" distinct from its circumference). - ArcLength, } /// Constrains a single line's length (or its X/Y-only component) between -/// its two endpoints, or a circle/arc's radius (or diameter, or — for an -/// arc specifically — its swept arc length), to a typed target value. +/// its two endpoints, or a circle/arc's radius or diameter, to a typed target value. pub struct DistanceConstraintCommand { command_name: &'static str, handle: Handle, @@ -119,16 +166,10 @@ pub struct DistanceConstraintCommand { /// (Distance/DistanceX/DistanceY) — decided once at construction from /// the entity's type. is_circle: bool, - /// Whether `handle` is specifically an Arc (as opposed to a full - /// Circle) — gates the `[Arclength]` prompt option. - is_arc: bool, mode: DistanceMode, default_value: f64, default_x: f64, default_y: f64, - /// The entity's current arc length, for `ArcLength` mode's `` - /// — meaningless (left at 0) unless `is_arc`. - default_arc_length: f64, /// Snapshot of `Scene::named_parameters`' names at construction time — /// see `parse_driving_value`'s doc comment for why a snapshot. known_param_names: Vec, @@ -147,17 +188,16 @@ impl DistanceConstraintCommand { requested_mode: DistanceMode, ) -> Option { let entity = scene.document.get_entity(handle)?; - let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) = - match entity { - EntityType::Line(l) => { - let dx = l.end.x - l.start.x; - let dy = l.end.y - l.start.y; - (false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0) - } - EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0), - EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()), - _ => return None, - }; + let (is_circle, default_value, default_x, default_y) = match entity { + EntityType::Line(l) => { + let dx = l.end.x - l.start.x; + let dy = l.end.y - l.start.y; + (false, (dx * dx + dy * dy).sqrt(), dx, dy) + } + EntityType::Circle(c) => (true, c.radius, 0.0, 0.0), + EntityType::Arc(a) => (true, a.radius, 0.0, 0.0), + _ => return None, + }; let known_param_names = scene .named_parameters() .iter() @@ -165,7 +205,11 @@ impl DistanceConstraintCommand { .collect(); let mode = match requested_mode { DistanceMode::Linear if !is_circle => { - if default_x.abs() >= default_y.abs() { DistanceMode::X } else { DistanceMode::Y } + if default_x.abs() >= default_y.abs() { + DistanceMode::X + } else { + DistanceMode::Y + } } DistanceMode::Aligned if !is_circle => DistanceMode::Aligned, DistanceMode::Radius if is_circle => DistanceMode::Radius, @@ -178,12 +222,10 @@ impl DistanceConstraintCommand { command_name, handle, is_circle, - is_arc, mode, default_value, default_x, default_y, - default_arc_length, known_param_names, }) } @@ -198,46 +240,41 @@ impl DistanceConstraintCommand { let (kind, refs, label) = match (self.is_circle, self.mode) { (true, DistanceMode::Diameter) => ( ConstraintKind::Diameter, - vec![SketchRef::whole(self.handle)], + vec![ParametricRef::whole(self.handle)], "Diameter constraint", ), - (true, DistanceMode::ArcLength) if self.is_arc => ( - ConstraintKind::ArcLength, - vec![SketchRef::whole(self.handle)], - "Arc length constraint", - ), (true, DistanceMode::Auto | DistanceMode::Radius) => ( ConstraintKind::Radius, - vec![SketchRef::whole(self.handle)], + vec![ParametricRef::whole(self.handle)], "Radius constraint", ), (false, DistanceMode::X) => ( ConstraintKind::DistanceX, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "DistanceX constraint", ), (false, DistanceMode::Y) => ( ConstraintKind::DistanceY, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "DistanceY constraint", ), (false, DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear) => ( ConstraintKind::Distance, vec![ - SketchRef::point(self.handle, 0), - SketchRef::point(self.handle, 1), + ParametricRef::point(self.handle, 0), + ParametricRef::point(self.handle, 1), ], "Distance constraint", ), _ => return None, }; - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, refs, driving_param: Some(target), @@ -257,16 +294,6 @@ impl CadCommand for DistanceConstraintCommand { "Specify diameter <{:.4}> or [Radius]: ", self.default_value * 2.0 ), - (true, DistanceMode::ArcLength) if self.is_arc => { - format!( - "Specify arc length <{:.4}> or [Radius]: ", - self.default_arc_length - ) - } - (true, DistanceMode::Auto | DistanceMode::Radius) if self.is_arc => format!( - "Specify distance <{:.4}> or [Diameter/Arclength]: ", - self.default_value - ), (true, DistanceMode::Auto | DistanceMode::Radius) => format!( "Specify distance <{:.4}> or [Diameter]: ", self.default_value @@ -292,10 +319,12 @@ impl CadCommand for DistanceConstraintCommand { fn on_enter(&mut self) -> CmdResult { let default = match self.mode { DistanceMode::Diameter => self.default_value * 2.0, - DistanceMode::ArcLength => self.default_arc_length, DistanceMode::X => self.default_x, DistanceMode::Y => self.default_y, - DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear | DistanceMode::Radius => self.default_value, + DistanceMode::Auto + | DistanceMode::Aligned + | DistanceMode::Linear + | DistanceMode::Radius => self.default_value, }; self.build(DrivingValue::Literal(default)) .unwrap_or(CmdResult::Cancel) @@ -314,10 +343,6 @@ impl CadCommand for DistanceConstraintCommand { self.mode = DistanceMode::Diameter; return Some(CmdResult::NeedPoint); } - (true, "A" | "ARCLENGTH") if self.is_arc => { - self.mode = DistanceMode::ArcLength; - return Some(CmdResult::NeedPoint); - } (true, "R" | "RADIUS") => { self.mode = DistanceMode::Radius; return Some(CmdResult::NeedPoint); @@ -390,11 +415,11 @@ impl AngleConstraintCommand { if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) { return None; } - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind: ConstraintKind::Angle, refs: vec![ - SketchRef::whole(self.fixed_handle), - SketchRef::whole(self.moving_handle), + ParametricRef::whole(self.fixed_handle), + ParametricRef::whole(self.moving_handle), ], driving_param: Some(target), label: "Angle constraint", @@ -437,8 +462,15 @@ impl CadCommand for AngleConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &[ - "DCONSTRAINT", "ACONSTRAINT", "DCLINEAR", "DCHORIZONTAL", "DCVERTICAL", - "DCALIGNED", "DCANGULAR", "DCRADIUS", "DCDIAMETER", + "DCONSTRAINT", + "ACONSTRAINT", + "DCLINEAR", + "DCHORIZONTAL", + "DCVERTICAL", + "DCALIGNED", + "DCANGULAR", + "DCRADIUS", + "DCDIAMETER", ] }); @@ -509,17 +541,6 @@ mod tests { )) } - fn add_arc(scene: &mut Scene) -> Handle { - scene.add_entity(EntityType::Arc( - acadrust::entities::Arc::from_center_radius_angles( - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - 3.0, - 0.0, - std::f64::consts::FRAC_PI_2, - ), - )) - } - #[test] fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() { let mut scene = Scene::new(); @@ -537,7 +558,7 @@ mod tests { ); match cmd.on_text_input("10") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -545,7 +566,7 @@ mod tests { assert_eq!(kind, ConstraintKind::DistanceX); assert_eq!(v, 10.0); } - _ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"), + _ => panic!("expected an AddParametricConstraint{{DistanceX}}, got a different result"), } } @@ -558,7 +579,7 @@ mod tests { assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint))); assert!(matches!( cmd.on_text_input("-3"), - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind: ConstraintKind::DistanceX, driving_param: Some(DrivingValue::Literal(-3.0)), .. @@ -576,7 +597,7 @@ mod tests { assert!(cmd.prompt().contains("<8.0000>")); assert!(matches!( cmd.on_enter(), - CmdResult::AddSketchConstraint { + CmdResult::AddParametricConstraint { kind: ConstraintKind::DistanceY, driving_param: Some(DrivingValue::Literal(8.0)), .. @@ -609,7 +630,7 @@ mod tests { ); match cmd.on_text_input("16") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -617,7 +638,7 @@ mod tests { assert_eq!(kind, ConstraintKind::Diameter); assert_eq!(v, 16.0); } - _ => panic!("expected an AddSketchConstraint{{Diameter}}"), + _ => panic!("expected an AddParametricConstraint{{Diameter}}"), } } @@ -629,70 +650,10 @@ mod tests { DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); match cmd.on_text_input("5") { - Some(CmdResult::AddSketchConstraint { kind, .. }) => { + Some(CmdResult::AddParametricConstraint { kind, .. }) => { assert_eq!(kind, ConstraintKind::Radius) } - _ => panic!("expected an AddSketchConstraint{{Radius}}"), + _ => panic!("expected an AddParametricConstraint{{Radius}}"), } } - - #[test] - fn a_keyword_switches_an_arcs_command_to_arc_length_mode() { - let mut scene = Scene::new(); - let arc = add_arc(&mut scene); - let mut cmd = - DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); - - assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint))); - assert!( - cmd.prompt().contains("arc length"), - "prompt should reflect ArcLength mode: {}", - cmd.prompt() - ); - - match cmd.on_text_input("8") { - Some(CmdResult::AddSketchConstraint { - kind, - driving_param: Some(DrivingValue::Literal(v)), - .. - }) => { - assert_eq!(kind, ConstraintKind::ArcLength); - assert_eq!(v, 8.0); - } - _ => panic!("expected an AddSketchConstraint{{ArcLength}}"), - } - } - - #[test] - fn arc_length_keyword_is_not_offered_for_a_plain_circle() { - let mut scene = Scene::new(); - let circle = add_circle(&mut scene); - let mut cmd = - DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); - - assert!( - !cmd.prompt().contains("Arclength"), - "a plain circle has no arc length option: {}", - cmd.prompt() - ); - // "A" isn't a recognized keyword here and doesn't parse as a - // number or a named parameter either, so it's rejected outright. - assert!(cmd.on_text_input("A").is_none()); - } - - #[test] - fn arc_length_default_is_the_arcs_current_swept_length() { - let mut scene = Scene::new(); - let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2 - let mut cmd = - DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT"); - cmd.on_text_input("A"); - - let expected = 3.0 * std::f64::consts::FRAC_PI_2; - assert!( - cmd.prompt().contains(&format!("{expected:.4}")), - "prompt should default to the arc's current length: {}", - cmd.prompt() - ); - } } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 8069e6eea..e66fef3b3 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -1,13 +1,13 @@ use acadrust::entities::Dimension; use acadrust::objects::{ - AssocDimensionAssociation, AssocDimensionReference, AssociativeData, - AssociativeObject, ObjectType, + AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject, + ObjectType, }; use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use cadkernel::geom2d::{ - angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, - BulgeArc, Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, + angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, BulgeArc, + Circle as KernelCircle, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN, }; use cadkernel::space::Plane; use std::f64::consts::TAU; @@ -25,8 +25,7 @@ pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 { } fn polyline_arc_segment_from_point_marker(marker: i32) -> Option { - (marker <= POLYLINE_ARC_POINT_MARKER_BASE) - .then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker) + (marker <= POLYLINE_ARC_POINT_MARKER_BASE).then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker) } #[derive(Clone, Copy, Debug)] @@ -158,11 +157,13 @@ pub(crate) fn radial_source_at( entity: &EntityType, point: Vector3, ) -> Option { - radial_candidates(entity).into_iter().min_by(|first, second| { - first - .distance_squared_to(dpoint(point)) - .total_cmp(&second.distance_squared_to(dpoint(point))) - }) + radial_candidates(entity) + .into_iter() + .min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) } fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { @@ -177,14 +178,16 @@ fn radial_source_matching( radius: f64, chord: Vector3, ) -> Option { - radial_candidates(entity).into_iter().min_by(|first, second| { - let score = |candidate: &RadialSourceGeometry| { - point_distance_squared(candidate.center_world(), center) - + (candidate.radius - radius).powi(2) - + candidate.distance_squared_to(dpoint(chord)) * 1e-6 - }; - score(first).total_cmp(&score(second)) - }) + radial_candidates(entity) + .into_iter() + .min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) } fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { @@ -235,9 +238,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { EntityType::LwPolyline(polyline) => { let count = polyline.vertices.len(); let first = *polyline.vertices.get(segment)?; - let second = *polyline - .vertices - .get(next_segment_index(count, polyline.is_closed, segment)?)?; + let second = + *polyline + .vertices + .get(next_segment_index(count, polyline.is_closed, segment)?)?; bulge_center_world( [first.location.x, first.location.y], [second.location.x, second.location.y], @@ -249,9 +253,10 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { EntityType::Polyline2D(polyline) => { let count = polyline.vertices.len(); let first = polyline.vertices.get(segment)?; - let second = polyline - .vertices - .get(next_segment_index(count, polyline.is_closed(), segment)?)?; + let second = + polyline + .vertices + .get(next_segment_index(count, polyline.is_closed(), segment)?)?; bulge_center_world( [first.location.x, first.location.y], [second.location.x, second.location.y], @@ -267,20 +272,24 @@ fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { /// Ordered, named points for an entity (line start/end, polyline vertices, /// ...), indexed by the same non-negative GsMarker convention /// `AssocDimensionReference::main_gs_marker` uses. Promoted to -/// `pub(crate)` so `sketch::constraint_set` can address constraint +/// `pub(crate)` so the parametric solver can address constraint /// endpoints the same way associative dimensions already address theirs, /// rather than inventing a second sub-element scheme. pub(crate) fn source_points(entity: &EntityType) -> Vec { match entity { EntityType::Line(line) => vec![line.start, line.end], + EntityType::Ray(ray) => vec![ray.base_point], EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], EntityType::Circle(_) => Vec::new(), EntityType::Spline(spline) => crate::entities::spline::nurbs3(spline) .map(|curve| { let (start, end) = curve.domain(); - [curve.point_at_knot(start), curve.point_at_knot(end)].into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])).collect() - }).unwrap_or_default(), + [curve.point_at_knot(start), curve.point_at_knot(end)] + .into_iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect() + }) + .unwrap_or_default(), EntityType::LwPolyline(polyline) => polyline .vertices .iter() @@ -345,8 +354,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { .into_iter() .enumerate() .min_by(|(_, first), (_, second)| { - point_distance_squared(*first, point) - .total_cmp(&point_distance_squared(*second, point)) + point_distance_squared(*first, point).total_cmp(&point_distance_squared(*second, point)) }) .map(|(index, _)| index as i32) } @@ -360,9 +368,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); return Some(radial.point_at_angle(angle)); } - if let Some(segment) = - polyline_arc_segment_from_point_marker(reference.main_gs_marker) - { + if let Some(segment) = polyline_arc_segment_from_point_marker(reference.main_gs_marker) { let radial = radial_source_for_marker(entity, segment)?; let sweep = positive_sweep(radial.start_angle, radial.end_angle); let angle = radial.start_angle + sweep * reference.osnap_distance.clamp(0.0, 1.0); @@ -381,9 +387,7 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti } if reference.main_gs_marker == -2 { return match entity { - EntityType::Circle(circle) => { - Some(circle.point_at_angle_wcs(reference.osnap_distance)) - } + EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)), EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), _ => None, }; @@ -399,14 +403,17 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti ); let curve = circle_curve(circle); let stored_parameter = curve.parameter_at([stored.0, stored.1]) * TAU; - let parameter = if reference.osnap_distance.abs() > 1e-12 - || stored_parameter.abs() <= 1e-12 + let parameter = if reference.osnap_distance.abs() > 1e-12 || stored_parameter.abs() <= 1e-12 { reference.osnap_distance } else { stored_parameter }; - let parameter = if parameter.is_finite() { parameter } else { 0.0 }; + let parameter = if parameter.is_finite() { + parameter + } else { + 0.0 + }; let point = curve.point_at(parameter / TAU); return Some(ocs_point( point[0], @@ -494,27 +501,13 @@ fn signed_angle_delta(value: f64) -> f64 { (value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI } -fn angle_about_plane( - plane: Plane, - center: Vector3, - point: Vector3, -) -> f64 { - let delta = [ - point.x - center.x, - point.y - center.y, - point.z - center.z, - ]; - let dot = |axis: [f64; 3]| { - delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] - }; +fn angle_about_plane(plane: Plane, center: Vector3, point: Vector3) -> f64 { + let delta = [point.x - center.x, point.y - center.y, point.z - center.z]; + let dot = |axis: [f64; 3]| delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]; dot(plane.y_axis).atan2(dot(plane.x_axis)) } -fn point_on_radial_circle( - radial: RadialSourceGeometry, - radius: f64, - angle: f64, -) -> Vector3 { +fn point_on_radial_circle(radial: RadialSourceGeometry, radius: f64, angle: f64) -> Vector3 { vector3(radial.plane.point_at([ radial.center[0] + radius * angle.cos(), radial.center[1] + radius * angle.sin(), @@ -522,9 +515,7 @@ fn point_on_radial_circle( } fn plane_from_normal(origin: Vector3, normal: Vector3) -> Plane { - let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(( - normal.x, normal.y, normal.z, - )); + let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); Plane::from_axes( [origin.x, origin.y, origin.z], [x_axis.0, x_axis.1, x_axis.2], @@ -574,35 +565,52 @@ pub(crate) fn constraint_from_associative_dimension( document: &acadrust::CadDocument, handle: Handle, ) -> Option<( - super::sketch_constraints::ConstraintKind, - Vec, + super::parametric_constraints::ConstraintKind, + Vec, super::named_parameters::DrivingValue, )> { use super::named_parameters::DrivingValue; - use super::sketch_constraints::{ConstraintKind, SketchRef}; + use super::parametric_constraints::{ConstraintKind, ParametricRef}; - let EntityType::Dimension(dimension) = document.get_entity(handle)? else { return None }; + let EntityType::Dimension(dimension) = document.get_entity(handle)? else { + return None; + }; let association = document.objects.values().find_map(|object| { - let ObjectType::Associative(object) = object else { return None }; - let AssociativeData::DimensionAssociation(association) = &object.data else { return None }; + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; (association.dimension == handle && association.associativity != 0).then_some(association) })?; let point = |index: usize| { let reference = association.references.get(index)?.first()?; - Some(SketchRef::point(*reference.xrefs.first()?, reference.main_gs_marker)) + Some(ParametricRef::point( + *reference.xrefs.first()?, + reference.main_gs_marker, + )) }; let whole = |index: usize| { let reference = association.references.get(index)?.first()?; - Some(SketchRef::whole(*reference.xrefs.first()?)) + Some(ParametricRef::whole(*reference.xrefs.first()?)) }; let measurement = DrivingValue::Literal(dimension.measurement()); match dimension { - Dimension::Aligned(_) => Some((ConstraintKind::Distance, vec![point(0)?, point(1)?], measurement)), + Dimension::Aligned(_) => Some(( + ConstraintKind::Distance, + vec![point(0)?, point(1)?], + measurement, + )), Dimension::Linear(linear) => { let direction = [linear.rotation.cos().abs(), linear.rotation.sin().abs()]; - let kind = if direction[1] <= 1e-9 { ConstraintKind::DistanceX } - else if direction[0] <= 1e-9 { ConstraintKind::DistanceY } - else { return None }; + let kind = if direction[1] <= 1e-9 { + ConstraintKind::DistanceX + } else if direction[0] <= 1e-9 { + ConstraintKind::DistanceY + } else { + return None; + }; Some((kind, vec![point(0)?, point(1)?], measurement)) } Dimension::Radius(_) | Dimension::LargeRadial(_) => { @@ -610,16 +618,24 @@ pub(crate) fn constraint_from_associative_dimension( } Dimension::Diameter(_) => Some((ConstraintKind::Diameter, vec![whole(0)?], measurement)), Dimension::Angular2Ln(_) => { - let mut sources: Vec = association.references.iter().flatten() - .filter_map(|reference| reference.xrefs.first().copied()).collect(); + let mut sources: Vec = association + .references + .iter() + .flatten() + .filter_map(|reference| reference.xrefs.first().copied()) + .collect(); sources.sort_unstable(); sources.dedup(); - let [first, second] = sources.as_slice() else { return None }; - Some((ConstraintKind::Angle, - vec![SketchRef::whole(*first), SketchRef::whole(*second)], measurement)) + let [first, second] = sources.as_slice() else { + return None; + }; + Some(( + ConstraintKind::Angle, + vec![ParametricRef::whole(*first), ParametricRef::whole(*second)], + measurement, + )) } - Dimension::Arc(_) => Some((ConstraintKind::ArcLength, vec![whole(0)?], measurement)), - Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None, + Dimension::Arc(_) | Dimension::Angular3Pt(_) | Dimension::Ordinate(_) => None, } } @@ -716,8 +732,7 @@ impl Scene { dimension: Handle, sources: Vec>, ) { - let Some(EntityType::Dimension(dimension_entity)) = - self.document.get_entity(dimension) + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) else { return; }; @@ -803,22 +818,20 @@ impl Scene { return; } - let reference = |source: Handle, - marker: i32, - parameter: f64, - osnap_type: u8, - point: Vector3| AssocDimensionReference { - class_name: "AcDbOsnapPointRef".to_string(), - osnap_type, - xrefs: vec![source], - main_subent_type: 1, - main_gs_marker: marker, - osnap_distance: parameter, - osnap_point: point, - ..AssocDimensionReference::default() - }; - let mut references: [Vec; 4] = - std::array::from_fn(|_| Vec::new()); + let reference = + |source: Handle, marker: i32, parameter: f64, osnap_type: u8, point: Vector3| { + AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + osnap_point: point, + ..AssocDimensionReference::default() + } + }; + let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); let mut associativity = 0; for (index, resolved) in resolved.into_iter().enumerate() { if index >= references.len() { @@ -873,12 +886,8 @@ impl Scene { } } - pub(crate) fn infer_dimension_sources( - &self, - dimension: Handle, - ) -> Vec> { - let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) - else { + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); }; let radial_data = match entity { @@ -906,18 +915,11 @@ impl Scene { .entities() .filter(|candidate| candidate.common().handle != dimension) .filter_map(|candidate| { - let radial = radial_source_matching( - candidate, - center, - radius, - chord, - )?; + let radial = radial_source_matching(candidate, center, radius, chord)?; let center_error = point_distance_squared(radial.center_world(), center); let radius_error = (radial.radius - radius).powi(2); - (center_error + radius_error <= tolerance * tolerance).then_some(( - center_error + radius_error, - candidate.common().handle, - )) + (center_error + radius_error <= tolerance * tolerance) + .then_some((center_error + radius_error, candidate.common().handle)) }) .min_by(|first, second| first.0.total_cmp(&second.0)) .map(|(_, handle)| handle); @@ -946,8 +948,7 @@ impl Scene { &mut self, changes: &[(Handle, ChangeKind)], ) -> Vec<(Handle, ChangeKind)> { - let changed: rustc_hash::FxHashSet<_> = - changes.iter().map(|(handle, _)| *handle).collect(); + let changed: rustc_hash::FxHashSet<_> = changes.iter().map(|(handle, _)| *handle).collect(); if changed.is_empty() { return Vec::new(); } @@ -966,7 +967,12 @@ impl Scene { .references .iter() .flatten() - .any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) + .any(|reference| { + reference + .xrefs + .iter() + .any(|handle| changed.contains(handle)) + }) .then_some(association.clone()) }) .collect(); @@ -989,9 +995,7 @@ impl Scene { let entity = self.document.get_entity(source)?; let segment = match reference.main_gs_marker { -3 => 0, - POLYLINE_ARC_CENTER_MARKER => { - reference.osnap_distance.round().max(0.0) as i32 - } + POLYLINE_ARC_CENTER_MARKER => reference.osnap_distance.round().max(0.0) as i32, _ => return None, }; radial_source_for_marker(entity, segment) @@ -1144,10 +1148,7 @@ impl Scene { let old_dim_radius = old_center.distance(&old_definition); let radial_offset = old_dim_radius - old_source_radius; let old_mid = arc.arc_start_parameter - + positive_sweep( - arc.arc_start_parameter, - arc.arc_end_parameter, - ) * 0.5; + + positive_sweep(arc.arc_start_parameter, arc.arc_end_parameter) * 0.5; let old_definition_angle = angle_about_plane(old_plane, old_center, old_definition); let definition_angle_offset = @@ -1208,8 +1209,7 @@ impl Scene { arc.base.insertion_point = definition; } if arc.has_leader { - arc.first_leader_point = - point_on_radial_circle(radial, dim_radius, middle); + arc.first_leader_point = point_on_radial_circle(radial, dim_radius, middle); arc.second_leader_point = second_leader; } arc.base.actual_measurement = arc.measurement(); @@ -1228,7 +1228,9 @@ impl Scene { fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let plane = plane_from_normal(linear.first_point, linear.base.normal); - let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]); + let first = plane + .project(dpoint(linear.first_point)) + .unwrap_or([0.0; 2]); let second = plane.project(dpoint(linear.second_point)).unwrap_or(first); let axis = [linear.rotation.cos(), linear.rotation.sin()]; ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 11e0a7da7..df6440aa0 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1,5 +1,4 @@ -//! Maps sketch constraints to the drawing format's native associative object graph. -//! The application record remains authoritative when both representations exist. +//! Maps parametric constraints to the drawing format's associative object graph. use acadrust::objects::{ Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode, @@ -12,8 +11,9 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::FxHashMap; use super::named_parameters::{DrivingValue, ParameterTable}; -use super::sketch_constraints::{ - ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, SketchScope, +use super::parametric_constraints::{ + distance_direction_type, ConstraintKind, NativeConstraintOrigin, ParametricConstraint, + ParametricConstraintSet, ParametricRef, ParametricScope, }; use super::Scene; @@ -28,14 +28,15 @@ const FIRST_NODE_ID: i32 = 1; mod implicit_point_type { pub const START: u8 = 0; pub const END: u8 = 1; - #[allow(dead_code)] // reserved for SketchRef marker -2, not wired up yet + #[allow(dead_code)] // reserved for ParametricRef marker -2, not wired up yet pub const MID: u8 = 2; pub const CENTER: u8 = 3; + pub const DEFINE: u8 = 4; } /// One entity's constraint-group presence: its geometry node's id, plus /// whichever `ImplicitPoint` nodes have been created for it so far, keyed -/// by our own `SketchRef::marker` convention so a second constraint +/// by our own `ParametricRef::marker` convention so a second constraint /// referencing the same point reuses the same node instead of duplicating it. #[derive(Default)] struct EntityNodes { @@ -88,6 +89,7 @@ impl<'a> GroupBuilder<'a> { | AssocConstraintNodeData::Arc { .. } | AssocConstraintNodeData::Ellipse { .. } | AssocConstraintNodeData::BoundedEllipse { .. } + | AssocConstraintNodeData::Spline { .. } )); self.nodes.push(AssocConstraintNode { node_id, @@ -195,6 +197,33 @@ impl<'a> GroupBuilder<'a> { end_point: l.end, }; } + EntityType::Ray(ray) => { + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: ray.base_point, + direction: ray.direction, + is_ray: true, + start_point: ray.base_point, + end_point: ray.base_point + ray.direction, + }, + ); + } + EntityType::XLine(line) => { + self.push_node( + node_id, + "AcConstrainedLine", + AssocConstraintNodeData::Line { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: line.base_point, + direction: line.direction, + }, + ); + } EntityType::Circle(c) => { let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); @@ -286,6 +315,78 @@ impl<'a> GroupBuilder<'a> { }; self.push_node(node_id, class_name, data); } + EntityType::Spline(spline) => { + let curve = crate::entities::spline::nurbs3(spline)?; + let weights = curve.weights(); + let rational = spline.flags.rational + || weights.iter().any(|weight| (*weight - 1.0).abs() > 1.0e-12); + let control_points: Vec<_> = curve + .control_points() + .iter() + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect(); + let mut implicit_point_ids = Vec::with_capacity(control_points.len()); + self.push_node( + node_id, + "AcConstrainedSpline", + AssocConstraintNodeData::Spline { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + rational, + periodic: spline.flags.periodic, + degree: curve.degree() as i32, + knot_tolerance: spline.knot_tolerance.max(1.0e-10), + knot_physical_length: curve.knots().len() as i32, + knot_grow_length: 8, + knots: curve.knots().to_vec(), + weight_physical_length: if rational { weights.len() as i32 } else { 0 }, + weight_grow_length: 8, + weights: if rational { + weights.to_vec() + } else { + Vec::new() + }, + control_point_physical_length: control_points.len() as i32, + control_point_grow_length: 8, + control_points, + implicit_point_ids: Vec::new(), + }, + ); + for point_index in 0..curve.control_points().len() { + let point_id = self.alloc_node_id(); + self.push_node( + point_id, + "AcConstrainedImplicitPoint", + AssocConstraintNodeData::ImplicitPoint { + geometry_dependency: Handle::NULL, + geometry_node_id: point_id, + point: None, + point_type: implicit_point_type::DEFINE, + point_index: point_index as i32, + curve_id: node_id, + }, + ); + implicit_point_ids.push(point_id); + } + if let Some(AssocConstraintNode { + data: + AssocConstraintNodeData::Spline { + implicit_point_ids: ids, + .. + }, + .. + }) = self.nodes.iter_mut().find(|node| node.node_id == node_id) + { + *ids = implicit_point_ids.clone(); + } + let points = &mut self.entities.entry(handle).or_default().points; + if let Some(first) = implicit_point_ids.first() { + points.insert(0, *first); + } + if let Some(last) = implicit_point_ids.last() { + points.insert(1, *last); + } + } _ => return None, } self.entities.entry(handle).or_default().geometry_node_id = node_id; @@ -305,11 +406,17 @@ impl<'a> GroupBuilder<'a> { { return Some(*existing); } + let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index(); let polyline = matches!( self.document.get_entity(handle), Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) ); - let (curve_id, point_type) = if polyline && marker >= 0 { + let (curve_id, point_type) = if let Some(segment) = segment_midpoint { + ( + self.segment_node(handle, segment)?, + implicit_point_type::MID, + ) + } else if polyline && marker >= 0 { let entity = self.document.get_entity(handle)?; let points = super::dimension_assoc::source_points(entity); let closed = match entity { @@ -329,9 +436,17 @@ impl<'a> GroupBuilder<'a> { (self.segment_node(handle, segment)?, point_type) } else { let curve_id = self.geometry_node(handle)?; + if let Some(existing) = self + .entities + .get(&handle) + .and_then(|entity| entity.points.get(&marker)) + { + return Some(*existing); + } let point_type = match marker { 0 => implicit_point_type::START, 1 => implicit_point_type::END, + -2 => implicit_point_type::MID, -3 => implicit_point_type::CENTER, _ => return None, }; @@ -350,22 +465,28 @@ impl<'a> GroupBuilder<'a> { curve_id, }, ); - let relation_id = self.alloc_node_id(); - self.push_node( - relation_id, - if point_type == implicit_point_type::CENTER { - "AcCenterPointConstraint" - } else { - "AcPointCurveConstraint" - }, - AssocConstraintNodeData::Geometrical { - owner_id: 0, - is_implied: false, - is_active: true, - }, - ); - self.connect(relation_id, node_id); - self.connect(relation_id, curve_id); + let mut relations = vec![if point_type == implicit_point_type::CENTER { + "AcCenterPointConstraint" + } else { + "AcPointCurveConstraint" + }]; + if point_type == implicit_point_type::MID { + relations.push("AcMidPointConstraint"); + } + for class_name in relations { + let relation_id = self.alloc_node_id(); + self.push_node( + relation_id, + class_name, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: true, + is_active: true, + }, + ); + self.connect(relation_id, node_id); + self.connect(relation_id, curve_id); + } self.entities .entry(handle) .or_default() @@ -374,9 +495,12 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } - /// The node id `SketchRef` resolves to: a point node for a marked + /// The node id `ParametricRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. - fn ref_node(&mut self, r: SketchRef) -> Option { + fn ref_node(&mut self, r: ParametricRef) -> Option { + if r.segment_midpoint_index().is_some() { + return self.point_node(r.entity, r.marker?); + } if let Some(segment) = r.segment_index() { return self.segment_node(r.entity, segment); } @@ -509,7 +633,6 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"), ConstraintKind::Perpendicular => Some("AcPerpendicularConstraint"), ConstraintKind::Tangent => Some("AcTangentConstraint"), - ConstraintKind::Smooth => Some("AcG2SmoothConstraint"), // the native format's own `GeomConstraintType` enum (native SDK's // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` // as a distinct, real geometric-constraint kind. @@ -528,6 +651,7 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { ConstraintKind::EqualDistance => Some("AcEqualDistanceConstraint"), // `Equal` needs the resolved entity types, so the caller handles it. ConstraintKind::Equal + | ConstraintKind::Smooth | ConstraintKind::Horizontal | ConstraintKind::Vertical | ConstraintKind::Parallel @@ -536,15 +660,10 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { | ConstraintKind::Radius | ConstraintKind::Diameter | ConstraintKind::DistanceX - | ConstraintKind::DistanceY => None, - // No native DWG representation exists at all: neither - // `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no - // ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData` - // has anything for it. `constraint_node` below falls through to - // its own `_ => None` for this kind, same "can't persist to DWG, - // XRecord-only" gap the dependency-chain DXF omission already - // documents. - ConstraintKind::ArcLength => None, + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + | ConstraintKind::Angle3Point + | ConstraintKind::RigidSet => None, } } @@ -555,10 +674,12 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { /// `constraint_node` below), not a distinct native class. const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { match kind { - ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => { - "AcDistanceConstraint" - } + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected => "AcDistanceConstraint", ConstraintKind::Angle => "AcAngleConstraint", + ConstraintKind::Angle3Point => "Ac3PointAngleConstraint", ConstraintKind::Radius | ConstraintKind::Diameter => "AcRadiusDiameterConstraint", _ => "", } @@ -571,29 +692,145 @@ const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { /// split, `AssocGeomDependency`/`AssocValueDependency` handles need mutable /// access to the same document, so this has to be two passes). `None` if /// this constraint's refs don't resolve to nodes this pass supports -/// (mirrors `sketch_solve::build_constraint`'s own "skip, don't error" +/// (mirrors `parametric_solve::build_constraint`'s own "skip, don't error" /// contract for an unbuildable constraint). fn constraint_node( builder: &mut GroupBuilder, document: &CadDocument, - constraint: &SketchConstraint, + constraint: &ParametricConstraint, needs_value_dependency: &mut Vec<(i32, Option)>, ) -> Option { - let refs: &[SketchRef] = &constraint.refs; + let refs: &[ParametricRef] = &constraint.refs; + if constraint.kind == ConstraintKind::Smooth { + let [first, second] = refs else { return None }; + let endpoint_parameter = |reference: ParametricRef| { + let entity = document.get_entity(reference.entity)?; + let EntityType::Spline(spline) = entity else { + return Some(None); + }; + if spline.flags.closed || spline.flags.periodic { + return None; + } + let curve = crate::entities::spline::nurbs3(spline)?; + let value = match reference.marker { + Some(0) => curve.knots().get(curve.degree()).copied(), + Some(1) => curve + .knots() + .get(curve.knots().len().checked_sub(curve.degree() + 1)?) + .copied(), + _ => None, + }?; + Some(Some(value)) + }; + let first_parameter = endpoint_parameter(*first)?; + let second_parameter = endpoint_parameter(*second)?; + if first_parameter.is_none() && second_parameter.is_none() { + return None; + } + let first_curve = builder.geometry_node(first.entity)?; + let second_curve = builder.geometry_node(second.entity)?; + let first_point = builder.ref_node(*first)?; + let second_point = builder.ref_node(*second)?; + + let coincidence = builder.alloc_node_id(); + builder.push_node( + coincidence, + "AcPointCoincidenceConstraint", + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + }, + ); + builder.connect(coincidence, first_point); + builder.connect(coincidence, second_point); + + let add_help_pair = |builder: &mut GroupBuilder, constraint_class| { + let mut add_help = |value: Option| { + value.map(|value| { + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "AcHelpParameter", + AssocConstraintNodeData::HelpParameter { + value, + reserved: false, + }, + ); + node_id + }) + }; + let first_help = add_help(first_parameter); + let second_help = add_help(second_parameter); + let child = builder.alloc_node_id(); + builder.push_node( + child, + constraint_class, + AssocConstraintNodeData::Geometrical { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + }, + ); + for target in [ + Some(first_curve), + Some(second_curve), + first_help, + second_help, + ] + .into_iter() + .flatten() + { + builder.connect(child, target); + } + if let Some(first_help) = first_help { + builder.connect(first_help, first_curve); + } + if let Some(second_help) = second_help { + builder.connect(second_help, second_curve); + } + child + }; + let tangent = add_help_pair(builder, "AcTangentConstraint"); + let equal_curvature = add_help_pair(builder, "AcEqualCurvatureConstraint"); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + "AcG2SmoothConstraint", + AssocConstraintNodeData::Composite { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + owned_constraint_ids: vec![coincidence, tangent, equal_curvature], + }, + ); + for child_id in [coincidence, tangent, equal_curvature] { + if let Some(AssocConstraintNode { + data: AssocConstraintNodeData::Geometrical { owner_id, .. }, + .. + }) = builder + .nodes + .iter_mut() + .find(|node| node.node_id == child_id) + { + *owner_id = node_id; + } + } + return Some(node_id); + } if let Some(class_name) = geometrical_class_name(constraint.kind) { let target_refs: Vec = refs.iter().filter_map(|r| builder.ref_node(*r)).collect(); if target_refs.len() != refs.len() { return None; } - let owner_id = *target_refs.first()?; let node_id = builder.alloc_node_id(); builder.push_node( node_id, class_name, AssocConstraintNodeData::Geometrical { - owner_id, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, }, ); for target in &target_refs { @@ -621,9 +858,9 @@ fn constraint_node( "AcVerticalConstraint" }, AssocConstraintNodeData::Parallel { - owner_id: targets[0], + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, datum_line_index: Some(datum), }, ); @@ -640,9 +877,9 @@ fn constraint_node( node_id, "AcParallelConstraint", AssocConstraintNodeData::Parallel { - owner_id: na, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, datum_line_index: None, }, ); @@ -653,7 +890,7 @@ fn constraint_node( ConstraintKind::Equal => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); - let is_straight = |reference: &SketchRef| { + let is_straight = |reference: &ParametricRef| { matches!( document.get_entity(reference.entity), Some(EntityType::Line(_)) @@ -674,9 +911,9 @@ fn constraint_node( node_id, class_name, AssocConstraintNodeData::Geometrical { - owner_id: na, + owner_id: 0, is_implied: false, - is_active: true, + is_active: constraint.enabled, }, ); builder.connect(node_id, na); @@ -686,29 +923,47 @@ fn constraint_node( ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected | ConstraintKind::Angle + | ConstraintKind::Angle3Point | ConstraintKind::Radius | ConstraintKind::Diameter => { let target_refs: Vec = match constraint.kind { ConstraintKind::Radius | ConstraintKind::Diameter => { vec![builder.ref_node(*refs.first()?)?] } + ConstraintKind::Angle3Point => { + let [a, vertex, b] = refs else { return None }; + vec![ + builder.ref_node(*a)?, + builder.ref_node(*vertex)?, + builder.ref_node(*b)?, + ] + } + ConstraintKind::DistanceDirected => { + if !matches!(refs.len(), 2 | 3) { + return None; + } + refs.iter() + .map(|reference| builder.ref_node(*reference)) + .collect::>()? + } _ => { let [a, b] = refs else { return None }; vec![builder.ref_node(*a)?, builder.ref_node(*b)?] } }; - let owner_id = *target_refs.first()?; + let owner_id = 0; let node_id = builder.alloc_node_id(); let data = match constraint.kind { // Plain two-point distance: undirected (`kNotDirected`). ConstraintKind::Distance => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 0, + direction_type: distance_direction_type::UNDIRECTED, distance: None, }, // X-only/Y-only: `kFixedDirection` with the fixed unit @@ -718,33 +973,50 @@ fn constraint_node( ConstraintKind::DistanceX => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 1, + direction_type: distance_direction_type::FIXED, distance: Some(Vector3::new(1.0, 0.0, 0.0)), }, ConstraintKind::DistanceY => AssocConstraintNodeData::Distance { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - direction_type: 1, + direction_type: distance_direction_type::FIXED, distance: Some(Vector3::new(0.0, 1.0, 0.0)), }, + ConstraintKind::DistanceDirected => AssocConstraintNodeData::Distance { + owner_id, + is_implied: false, + is_active: constraint.enabled, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + direction_type: constraint.distance_direction_type, + distance: constraint.distance_direction, + }, ConstraintKind::Angle => AssocConstraintNodeData::Angle { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, - sector_type: 0, + sector_type: constraint.angle_sector, + }, + ConstraintKind::Angle3Point => AssocConstraintNodeData::Angle { + owner_id, + is_implied: false, + is_active: constraint.enabled, + value_dependency: Handle::NULL, + dimension_dependency: Handle::NULL, + sector_type: constraint.angle_sector, }, ConstraintKind::Radius => AssocConstraintNodeData::RadiusDiameter { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, mode: 0, @@ -754,7 +1026,7 @@ fn constraint_node( ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter { owner_id, is_implied: false, - is_active: true, + is_active: constraint.enabled, value_dependency: Handle::NULL, dimension_dependency: Handle::NULL, mode: 1, @@ -874,6 +1146,14 @@ impl Allocator<'_> { resolved: f64, ) -> Handle { if let Some(&handle) = self.variables.get(name) { + if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::Variable(variable) = &mut object.data { + variable.action.owning_network = network_handle; + variable.expression = formula.to_string(); + variable.value = Self::numeric_eval(resolved); + } + } return handle; } let handle = self.insert_associative( @@ -949,6 +1229,10 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { geometry_dependency, .. } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } | AssocConstraintNodeData::BoundedLine { geometry_dependency, .. @@ -968,6 +1252,10 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { | AssocConstraintNodeData::BoundedEllipse { geometry_dependency, .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. } => { *geometry_dependency = handle; } @@ -1036,18 +1324,8 @@ fn ensure_associative_classes_registered(document: &mut CadDocument) { } } -/// Ensures `owner` has an extension dictionary through the public API. fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { - if let Some(handle) = document.extension_dictionary_handle(owner) { - return handle; - } - const BOOTSTRAP_KEY: &str = "OCS_DWG_NATIVE_BOOTSTRAP"; - document.ensure_xrecord(owner, BOOTSTRAP_KEY); - let dictionary_handle = document - .extension_dictionary_handle(owner) - .expect("ensure_xrecord just created the extension dictionary"); - remove_dictionary_entry(document, dictionary_handle, BOOTSTRAP_KEY); - dictionary_handle + document.ensure_extension_dictionary(owner) } /// Removes a dictionary entry and its owned object tree. @@ -1137,29 +1415,41 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { document.objects.remove(&root); } -/// Whether `owner` already has a native graph that must stay synchronized. -fn has_native_network(document: &CadDocument, owner: Handle) -> bool { - let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { - return false; +fn native_group_handles(document: &CadDocument, owner: Handle) -> Vec { + let Some(network_handle) = native_scope_network_handle(document, owner) else { + return Vec::new(); }; - let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else { - return false; - }; - dictionary.get(NETWORK_DICTIONARY_KEY).is_some() -} - -fn native_group(document: &CadDocument, owner: Handle) -> Option<&Assoc2dConstraintGroup> { - let network_handle = native_scope_network_handle(document, owner)?; - document.objects.values().find_map(|object| { + let mut handles = Vec::new(); + if let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(network), + .. + })) = document.objects.get(&network_handle) + { + handles.extend(network.actions.iter().filter_map(|action| { + matches!( + document.objects.get(&action.dependency), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(_), + .. + })) + ) + .then_some(action.dependency) + })); + } + for (handle, object) in &document.objects { let ObjectType::Associative(object) = object else { - return None; + continue; }; let AssociativeData::ConstraintGroup(group) = &object.data else { - return None; + continue; }; - (object.owner == network_handle || group.action.owning_network == network_handle) - .then_some(group) - }) + if (object.owner == network_handle || group.action.owning_network == network_handle) + && !handles.contains(handle) + { + handles.push(*handle); + } + } + handles } fn native_scope_network_handle(document: &CadDocument, owner: Handle) -> Option { @@ -1210,6 +1500,10 @@ fn geometry_dependency(data: &AssocConstraintNodeData) -> Option { | AssocConstraintNodeData::BoundedEllipse { geometry_dependency, .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. } if !geometry_dependency.is_null() => Some(*geometry_dependency), _ => None, } @@ -1240,10 +1534,13 @@ fn dependency_entity( ), AssocConstraintNodeData::Point { .. }, ) => true, + (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, ( - Some(EntityType::Line(_) | EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), - AssocConstraintNodeData::Line { .. } | AssocConstraintNodeData::BoundedLine { .. }, + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + AssocConstraintNodeData::BoundedLine { .. }, ) => true, + (Some(EntityType::Ray(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => *is_ray, + (Some(EntityType::XLine(_)), AssocConstraintNodeData::Line { .. }) => true, (Some(EntityType::Circle(_)), AssocConstraintNodeData::Circle { .. }) => true, (Some(EntityType::Arc(_)), AssocConstraintNodeData::Arc { .. }) => true, ( @@ -1251,25 +1548,29 @@ fn dependency_entity( AssocConstraintNodeData::Ellipse { .. } | AssocConstraintNodeData::BoundedEllipse { .. }, ) => true, + (Some(EntityType::Spline(_)), AssocConstraintNodeData::Spline { .. }) => true, _ => false, }; supported.then_some(entity) } -fn native_group_requires_preservation(document: &CadDocument, owner: Handle) -> bool { - native_group(document, owner).is_some_and(|group| { - group.nodes.iter().any(|node| { - if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { +fn group_requires_preservation(document: &CadDocument, group: &Assoc2dConstraintGroup) -> bool { + group.nodes.iter().any(|node| { + if matches!(node.data, AssocConstraintNodeData::RigidSet { .. }) { + return true; + } + if let Some(dependency) = geometry_dependency(&node.data) { + if dependency_entity(document, dependency, &node.data).is_none() { return true; } - if let Some(dependency) = geometry_dependency(&node.data) { - if dependency_entity(document, dependency, &node.data).is_none() { - return true; - } - } - node.class_name.to_ascii_uppercase().ends_with("CONSTRAINT") - && constraint_kind(node).is_none() - }) + } + let class_name = node.class_name.to_ascii_uppercase(); + class_name.ends_with("CONSTRAINT") + && constraint_kind(node).is_none() + && !matches!( + class_name.as_str(), + "ACEQUALCURVATURECONSTRAINT" | "ACEQUALHELPPARAMETERCONSTRAINT" + ) }) } @@ -1277,7 +1578,8 @@ fn polyline_segment_reference( document: &CadDocument, entity: Handle, data: &AssocConstraintNodeData, -) -> Option { + work_plane: &[Vector3; 3], +) -> Option { let AssocConstraintNodeData::BoundedLine { start_point, end_point, @@ -1286,6 +1588,15 @@ fn polyline_segment_reference( else { return None; }; + let [origin, axis_x, axis_y] = *work_plane; + let normal = Vector3::new( + axis_x.y * axis_y.z - axis_x.z * axis_y.y, + axis_x.z * axis_y.x - axis_x.x * axis_y.z, + axis_x.x * axis_y.y - axis_x.y * axis_y.x, + ); + let to_world = |point: Vector3| origin + axis_x * point.x + axis_y * point.y + normal * point.z; + let start_point = to_world(*start_point); + let end_point = to_world(*end_point); let entity_value = document.get_entity(entity)?; let closed = match entity_value { EntityType::LwPolyline(polyline) => polyline.is_closed, @@ -1299,15 +1610,13 @@ fn polyline_segment_reference( let score = |index: usize| { let a = points[index]; let b = points[(index + 1) % points.len()]; - let forward = - (a - *start_point).length_squared() + (b - *end_point).length_squared(); - let reverse = - (b - *start_point).length_squared() + (a - *end_point).length_squared(); + let forward = (a - start_point).length_squared() + (b - end_point).length_squared(); + let reverse = (b - start_point).length_squared() + (a - end_point).length_squared(); forward.min(reverse) }; score(*first).total_cmp(&score(*second)) }) - .map(|index| SketchRef::segment(entity, index)) + .map(|index| ParametricRef::segment(entity, index)) } fn numeric_value(value: &AssocEvalVariant) -> Option { @@ -1355,9 +1664,10 @@ fn driving_value( } } -fn constraint_owner(data: &AssocConstraintNodeData) -> Option { +fn owning_composite(data: &AssocConstraintNodeData) -> Option { match data { AssocConstraintNodeData::Geometrical { owner_id, .. } + | AssocConstraintNodeData::Composite { owner_id, .. } | AssocConstraintNodeData::Angle { owner_id, .. } | AssocConstraintNodeData::Parallel { owner_id, .. } | AssocConstraintNodeData::Distance { owner_id, .. } @@ -1369,6 +1679,7 @@ fn constraint_owner(data: &AssocConstraintNodeData) -> Option { fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { match data { AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Composite { is_active, .. } | AssocConstraintNodeData::Angle { is_active, .. } | AssocConstraintNodeData::Parallel { is_active, .. } | AssocConstraintNodeData::Distance { is_active, .. } @@ -1377,15 +1688,80 @@ fn constraint_is_active(data: &AssocConstraintNodeData) -> bool { } } +fn constraint_is_implied(data: &AssocConstraintNodeData) -> bool { + match data { + AssocConstraintNodeData::Geometrical { is_implied, .. } + | AssocConstraintNodeData::Composite { is_implied, .. } + | AssocConstraintNodeData::Angle { is_implied, .. } + | AssocConstraintNodeData::Parallel { is_implied, .. } + | AssocConstraintNodeData::Distance { is_implied, .. } + | AssocConstraintNodeData::RadiusDiameter { is_implied, .. } => *is_implied, + _ => false, + } +} + +fn set_constraint_active(data: &mut AssocConstraintNodeData, active: bool) { + match data { + AssocConstraintNodeData::Geometrical { is_active, .. } + | AssocConstraintNodeData::Composite { is_active, .. } + | AssocConstraintNodeData::Angle { is_active, .. } + | AssocConstraintNodeData::Parallel { is_active, .. } + | AssocConstraintNodeData::Distance { is_active, .. } + | AssocConstraintNodeData::RadiusDiameter { is_active, .. } => *is_active = active, + _ => {} + } +} + +fn clear_removed_geometry_owner(data: &mut AssocConstraintNodeData, removed: &[i32]) { + let owner = match data { + AssocConstraintNodeData::ImplicitPoint { + geometry_node_id, .. + } + | AssocConstraintNodeData::Point { + geometry_node_id, .. + } + | AssocConstraintNodeData::RigidSet { + geometry_node_id, .. + } + | AssocConstraintNodeData::Line { + geometry_node_id, .. + } + | AssocConstraintNodeData::BoundedLine { + geometry_node_id, .. + } + | AssocConstraintNodeData::Circle { + geometry_node_id, .. + } + | AssocConstraintNodeData::Arc { + geometry_node_id, .. + } + | AssocConstraintNodeData::Ellipse { + geometry_node_id, .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_node_id, .. + } + | AssocConstraintNodeData::Spline { + geometry_node_id, .. + } => geometry_node_id, + _ => return, + }; + if removed.contains(owner) { + *owner = 0; + } +} + fn constraint_kind(node: &AssocConstraintNode) -> Option { Some(match node.class_name.to_ascii_uppercase().as_str() { + "ACCONSTRAINEDRIGIDSET" => ConstraintKind::RigidSet, "ACPOINTCOINCIDENCECONSTRAINT" => ConstraintKind::Coincident, "ACHORIZONTALCONSTRAINT" => ConstraintKind::Horizontal, "ACVERTICALCONSTRAINT" => ConstraintKind::Vertical, "ACPARALLELCONSTRAINT" => ConstraintKind::Parallel, "ACPERPENDICULARCONSTRAINT" => ConstraintKind::Perpendicular, "ACEQUALLENGTHCONSTRAINT" | "ACEQUALRADIUSCONSTRAINT" => ConstraintKind::Equal, - "ACANGLECONSTRAINT" | "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle, + "ACANGLECONSTRAINT" => ConstraintKind::Angle, + "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle3Point, "ACTANGENTCONSTRAINT" => ConstraintKind::Tangent, "ACG2SMOOTHCONSTRAINT" => ConstraintKind::Smooth, "ACCONCENTRICCONSTRAINT" => ConstraintKind::Concentric, @@ -1416,155 +1792,441 @@ fn constraint_kind(node: &AssocConstraintNode) -> Option { else { return None; }; - if *direction_type == 0 { + if *direction_type == distance_direction_type::UNDIRECTED { ConstraintKind::Distance - } else if distance.is_some_and(|direction| direction.x.abs() >= direction.y.abs()) { + } else if *direction_type == distance_direction_type::FIXED + && distance.is_some_and(|direction| { + let length = direction.x.hypot(direction.y); + length > f64::EPSILON + && (direction.x / length - 1.0).abs() < 1.0e-9 + && (direction.y / length).abs() < 1.0e-9 + }) + { ConstraintKind::DistanceX - } else { + } else if *direction_type == distance_direction_type::FIXED + && distance.is_some_and(|direction| { + let length = direction.x.hypot(direction.y); + length > f64::EPSILON + && (direction.x / length).abs() < 1.0e-9 + && (direction.y / length - 1.0).abs() < 1.0e-9 + }) + { ConstraintKind::DistanceY + } else { + ConstraintKind::DistanceDirected } } _ => return None, }) } +fn rigid_set_references( + document: &CadDocument, + geometry_ids: &[i32], + refs: &FxHashMap, +) -> (Vec, Vec<(ParametricRef, Vector3)>) { + let mut entities = Vec::new(); + let mut points = Vec::new(); + let mut add_point = |reference: ParametricRef, point: Vector3| { + if !points.iter().any(|(existing, _)| *existing == reference) { + points.push((reference, point)); + } + }; + for geometry_id in geometry_ids { + let Some(reference) = refs.get(geometry_id).copied() else { + continue; + }; + if !entities.iter().any(|existing| *existing == reference) { + entities.push(reference); + } + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(marker) = reference.marker { + if let Some(point) = super::parametric_constraints::resolve_point(entity, marker) { + add_point(reference, point); + } + continue; + } + for (marker, point) in super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + { + add_point(ParametricRef::point(reference.entity, marker as i32), point); + } + match entity { + EntityType::Circle(circle) => { + add_point(ParametricRef::center(reference.entity), circle.center_wcs()) + } + EntityType::Arc(arc) => { + add_point(ParametricRef::center(reference.entity), arc.center_wcs()) + } + _ => {} + } + } + (entities, points) +} + pub(super) fn native_constraint_set( document: &CadDocument, owner: Handle, parameters: &mut ParameterTable, -) -> Option { - let group = native_group(document, owner)?; - let mut refs = FxHashMap::default(); - for node in &group.nodes { - let Some(dependency) = geometry_dependency(&node.data) else { - continue; - }; - if let Some(entity) = dependency_entity(document, dependency, &node.data) { - let reference = polyline_segment_reference(document, entity, &node.data) - .unwrap_or_else(|| { - if matches!(node.data, AssocConstraintNodeData::Point { .. }) { - SketchRef::point(entity, 0) - } else { - SketchRef::whole(entity) +) -> Option { + let groups: Vec<_> = native_group_handles(document, owner) + .into_iter() + .filter_map(|handle| match document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) => Some((handle, group, group_requires_preservation(document, group))), + _ => None, + }) + .collect(); + if groups.is_empty() { + return None; + } + let scope = if owner == document.header.model_space_block_handle { + ParametricScope::ModelSpace + } else { + ParametricScope::Block(owner) + }; + let mut set = ParametricConstraintSet::new(scope); + for (group_handle, group, retained) in groups { + if retained { + set.retained_standard_groups.push(group_handle); + } + let mut refs = FxHashMap::default(); + for node in &group.nodes { + let Some(dependency) = geometry_dependency(&node.data) else { + continue; + }; + if let Some(entity) = dependency_entity(document, dependency, &node.data) { + let reference = + polyline_segment_reference(document, entity, &node.data, &group.work_plane) + .unwrap_or_else(|| { + if matches!(node.data, AssocConstraintNodeData::Point { .. }) { + ParametricRef::point(entity, 0) + } else { + ParametricRef::whole(entity) + } + }); + refs.insert(node.node_id, reference); + } + } + for node in &group.nodes { + let AssocConstraintNodeData::ImplicitPoint { + point_type, + point_index, + curve_id, + .. + } = node.data + else { + continue; + }; + let Some(curve) = refs.get(&curve_id).copied() else { + continue; + }; + let reference = if let Some(segment) = curve.segment_index() { + let Some(entity) = document.get_entity(curve.entity) else { + continue; + }; + let point_count = super::dimension_assoc::source_points(entity).len(); + match point_type { + implicit_point_type::START => { + ParametricRef::point(curve.entity, segment as i32) } - }); + implicit_point_type::END if point_count > 0 => { + ParametricRef::point(curve.entity, ((segment + 1) % point_count) as i32) + } + implicit_point_type::MID => { + ParametricRef::segment_midpoint(curve.entity, segment) + } + _ => continue, + } + } else { + let marker = match point_type { + implicit_point_type::START => 0, + implicit_point_type::END => 1, + implicit_point_type::MID => -2, + implicit_point_type::CENTER => -3, + implicit_point_type::DEFINE => { + let Some(point_count) = group.nodes.iter().find_map(|node| { + (node.node_id == curve_id) + .then_some(&node.data) + .and_then(|data| { + let AssocConstraintNodeData::Spline { control_points, .. } = + data + else { + return None; + }; + Some(control_points.len()) + }) + }) else { + continue; + }; + if point_index == 0 { + 0 + } else if usize::try_from(point_index).ok() == point_count.checked_sub(1) { + 1 + } else { + continue; + } + } + _ => continue, + }; + ParametricRef::point(curve.entity, marker) + }; refs.insert(node.node_id, reference); } + + for node in &group.nodes { + let Some(kind) = constraint_kind(node) else { + continue; + }; + if owning_composite(&node.data).is_some_and(|owner| owner != 0) { + continue; + } + if constraint_is_implied(&node.data) { + continue; + } + let enabled = constraint_is_active(&node.data); + let (targets, rigid_points) = match &node.data { + AssocConstraintNodeData::RigidSet { geometry_ids, .. } => { + rigid_set_references(document, geometry_ids, &refs) + } + AssocConstraintNodeData::Composite { + owned_constraint_ids, + .. + } if kind == ConstraintKind::Smooth => { + let endpoints = owned_constraint_ids + .iter() + .filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id)) + .find(|child| { + child + .class_name + .eq_ignore_ascii_case("AcPointCoincidenceConstraint") + }) + .map(|child| { + child + .connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect() + }) + .unwrap_or_default(); + (endpoints, Vec::new()) + } + _ => ( + node.connections + .iter() + .filter_map(|id| refs.get(id).copied()) + .collect(), + Vec::new(), + ), + }; + if matches!( + kind, + ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint + ) && targets.len() == 2 + && targets[0].entity == targets[1].entity + { + continue; + } + let parameter_table = if matches!(scope, ParametricScope::Block(_)) { + &mut set.local_parameters + } else { + &mut *parameters + }; + let driving = match &node.data { + AssocConstraintNodeData::Angle { + value_dependency, .. + } + | AssocConstraintNodeData::Distance { + value_dependency, .. + } + | AssocConstraintNodeData::RadiusDiameter { + value_dependency, .. + } => driving_value(document, *value_dependency, parameter_table), + _ => None, + }; + let valid_target_count = match kind { + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + matches!(targets.len(), 1 | 2) + } + ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => { + targets.len() == 1 + } + ConstraintKind::Symmetric | ConstraintKind::Angle3Point => targets.len() == 3, + ConstraintKind::DistanceDirected => matches!(targets.len(), 2 | 3), + ConstraintKind::EqualDistance => targets.len() == 4, + ConstraintKind::RigidSet => !targets.is_empty() && rigid_points.len() >= 2, + _ => targets.len() == 2, + }; + if !valid_target_count { + continue; + } + if matches!( + kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + | ConstraintKind::Angle + | ConstraintKind::Angle3Point + | ConstraintKind::Radius + | ConstraintKind::Diameter + ) && driving.is_none() + { + continue; + } + set.add(kind, targets, driving); + if let Some(constraint) = set.constraints.last_mut() { + constraint.enabled = enabled; + constraint.rigid_points = rigid_points; + if let AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } = &node.data + { + constraint.distance_direction_type = *direction_type; + constraint.distance_direction = *distance; + } + if let AssocConstraintNodeData::Angle { sector_type, .. } = &node.data { + constraint.angle_sector = *sector_type; + } + if retained { + constraint.native_origin = Some(NativeConstraintOrigin { + group: group_handle, + node: node.node_id, + }); + } + } + } } - for node in &group.nodes { - let AssocConstraintNodeData::ImplicitPoint { - point_type, - curve_id, + (!set.constraints.is_empty() || !set.retained_standard_groups.is_empty()).then_some(set) +} + +fn sync_retained_group_constraints(document: &mut CadDocument, set: &ParametricConstraintSet) { + let retained: FxHashMap<(Handle, i32), bool> = set + .constraints + .iter() + .filter_map(|constraint| { + constraint + .native_origin + .map(|origin| ((origin.group, origin.node), constraint.enabled)) + }) + .collect(); + for &group_handle in &set.retained_standard_groups { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), .. - } = node.data + })) = document.objects.get_mut(&group_handle) else { continue; }; - let Some(curve) = refs.get(&curve_id).copied() else { - continue; - }; - let marker = if let Some(segment) = curve.segment_index() { - let point_count = - super::dimension_assoc::source_points(document.get_entity(curve.entity)?).len(); - (match point_type { - implicit_point_type::START => segment, - implicit_point_type::END if point_count > 0 => (segment + 1) % point_count, - _ => continue, - }) as i32 - } else { - match point_type { - implicit_point_type::START => 0, - implicit_point_type::END => 1, - implicit_point_type::CENTER => -3, - _ => continue, + let mut removed = Vec::new(); + for node in &mut group.nodes { + if constraint_kind(node).is_none() || constraint_is_implied(&node.data) { + continue; } - }; - refs.insert(node.node_id, SketchRef::point(curve.entity, marker)); + match retained.get(&(group_handle, node.node_id)) { + Some(active) => set_constraint_active(&mut node.data, *active), + None => removed.push(node.node_id), + } + } + if removed.is_empty() { + continue; + } + group.nodes.retain(|node| !removed.contains(&node.node_id)); + for node in &mut group.nodes { + node.connections.retain(|id| !removed.contains(id)); + clear_removed_geometry_owner(&mut node.data, &removed); + } } - - let scope = if owner == document.header.model_space_block_handle { - SketchScope::ModelSpace - } else { - SketchScope::Block(owner) - }; - let mut set = SketchConstraintSet::new(scope); - for node in &group.nodes { - let Some(kind) = constraint_kind(node) else { - continue; - }; - if !constraint_is_active(&node.data) { - continue; - } - let mut targets: Vec = node - .connections - .iter() - .filter_map(|id| refs.get(id).copied()) - .collect(); - if let Some(owner) = constraint_owner(&node.data).and_then(|id| refs.get(&id).copied()) { - if !targets.contains(&owner) { - targets.insert(0, owner); - } - } - if matches!( - kind, - ConstraintKind::PointOnCurve | ConstraintKind::CenterPoint - ) && targets.len() == 2 - && targets[0].entity == targets[1].entity - { - continue; - } - let driving = match &node.data { - AssocConstraintNodeData::Angle { - value_dependency, .. - } - | AssocConstraintNodeData::Distance { - value_dependency, .. - } - | AssocConstraintNodeData::RadiusDiameter { - value_dependency, .. - } => driving_value(document, *value_dependency, parameters), - _ => None, - }; - let expected = match kind { - ConstraintKind::Fixed | ConstraintKind::Radius | ConstraintKind::Diameter => 1, - ConstraintKind::Symmetric => 3, - ConstraintKind::EqualDistance => 4, - _ => 2, - }; - if (matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) - && !matches!(targets.len(), 1 | 2)) - || (!matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) - && targets.len() != expected) - { - continue; - } - if matches!( - kind, - ConstraintKind::Distance - | ConstraintKind::DistanceX - | ConstraintKind::DistanceY - | ConstraintKind::Angle - | ConstraintKind::Radius - | ConstraintKind::Diameter - ) && driving.is_none() - { - continue; - } - set.add(kind, targets, driving); - } - (!set.constraints.is_empty()).then_some(set) } -/// One scope's worth of -/// [`Scene::materialize_dwg_native_constraints_for_save`] — see that -/// method's doc comment for the save-time contract this implements. +fn detach_preserved_scope( + document: &mut CadDocument, + owner: Handle, +) -> (FxHashMap, Vec) { + let Some(network_handle) = native_scope_network_handle(document, owner) else { + return (FxHashMap::default(), Vec::new()); + }; + let preserved_groups: Vec<_> = native_group_handles(document, owner) + .into_iter() + .filter(|handle| { + matches!( + document.objects.get(handle), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) if group_requires_preservation(document, group) + ) + }) + .collect(); + if preserved_groups.is_empty() { + return (FxHashMap::default(), Vec::new()); + } + + let mut variable_handles = Vec::new(); + for group_handle in &preserved_groups { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = document.objects.get(group_handle) + else { + continue; + }; + for dependency in &group.actions { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ValueDependency(value), + .. + })) = document.objects.get(dependency) + else { + continue; + }; + let handle = value.dependency.dependent_on; + if !handle.is_null() && !variable_handles.contains(&handle) { + variable_handles.push(handle); + } + } + } + + let dictionary = document + .extension_dictionary_handle(owner) + .unwrap_or(Handle::NULL); + for handle in &preserved_groups { + if let Some(ObjectType::Associative(object)) = document.objects.get_mut(handle) { + object.owner = dictionary; + } + } + let mut variables = FxHashMap::default(); + for handle in variable_handles { + let Some(ObjectType::Associative(object)) = document.objects.get_mut(&handle) else { + continue; + }; + object.owner = dictionary; + if let AssociativeData::Variable(variable) = &object.data { + variables.insert(variable.name.clone(), handle); + } + } + + debug_assert!(document.objects.contains_key(&network_handle)); + (variables, preserved_groups) +} + +/// Materializes one scope into the drawing's associative object graph. fn materialize_scope( document: &mut CadDocument, owner: Handle, - set: &SketchConstraintSet, + set: &ParametricConstraintSet, parameters: &ParameterTable, root_network_handle: Handle, action_index: i32, + existing_variables: FxHashMap, + preserved_groups: Vec, + materialize_all_parameters: bool, ) -> Option { let dictionary_handle = ensure_extension_dictionary(document, owner); remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); @@ -1574,9 +2236,6 @@ fn materialize_scope( let (mut nodes, entities, referenced_entities) = { let mut builder = GroupBuilder::new(document); for constraint in &set.constraints { - if !constraint.enabled { - continue; - } constraint_node( &mut builder, document, @@ -1592,33 +2251,61 @@ fn materialize_scope( } = builder; (nodes, entities, referenced_entities) }; - if nodes.is_empty() { + if nodes.is_empty() + && parameters.is_empty() + && existing_variables.is_empty() + && preserved_groups.is_empty() + { return None; } - // The stream starts with one synthetic root before the registered nodes. - nodes.insert( - 0, - AssocConstraintNode { - node_id: 0, - status: 0, - connections: Vec::new(), - class_name: String::new(), - registry_flag: false, - data: AssocConstraintNodeData::None, - }, - ); + if !nodes.is_empty() { + nodes.insert( + 0, + AssocConstraintNode { + node_id: 0, + status: 0, + connections: Vec::new(), + class_name: String::new(), + registry_flag: false, + data: AssocConstraintNodeData::None, + }, + ); + } // Pass 2 (mutable): allocate handles for everything the node shapes // above still reference as `Handle::NULL`. The group and network // reference each other, so both handles are reserved up front. - let group_handle = document.allocate_handle(); + let group_handle = (!nodes.is_empty()).then(|| document.allocate_handle()); let network_handle = document.allocate_handle(); let mut allocator = Allocator { document, - variables: FxHashMap::default(), - variable_actions: Vec::new(), + variable_actions: existing_variables.values().copied().collect(), + variables: existing_variables, }; + allocator.variable_actions.sort(); + for (index, handle) in allocator.variable_actions.iter().copied().enumerate() { + if let Some(ObjectType::Associative(object)) = allocator.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::Variable(variable) = &mut object.data { + variable.action.owning_network = network_handle; + variable.action.action_index = index as i32 + 1; + if let Some(parameter) = parameters.get(&variable.name) { + variable.expression = parameter.source.clone(); + if let Ok(resolved) = parameters.resolve(¶meter.name) { + variable.value = Allocator::numeric_eval(resolved); + } + } + } + } + } + + if materialize_all_parameters { + for parameter in parameters.iter() { + let resolved = parameters.resolve(¶meter.name).unwrap_or(0.0); + allocator.variable(network_handle, ¶meter.name, ¶meter.source, resolved); + } + } let mut geometry_dependencies = Vec::with_capacity(referenced_entities.len()); for (index, entity_handle) in referenced_entities.iter().enumerate() { @@ -1630,7 +2317,7 @@ fn materialize_scope( continue; } } - let dep_handle = allocator.geom_dependency(group_handle, *entity_handle, index as i32 + 1); + let dep_handle = allocator.geom_dependency(group_handle?, *entity_handle, index as i32 + 1); geometry_dependencies.push(dep_handle); for node_id in std::iter::once(entity_nodes.geometry_node_id) .filter(|node_id| *node_id != 0) @@ -1661,36 +2348,50 @@ fn materialize_scope( let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = - allocator.value_dependency(group_handle, variable_handle, resolved, dependency_id); + allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id); group_dependencies.push(dep_handle); if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { set_value_dependency(&mut node.data, dep_handle); } } - let group = Assoc2dConstraintGroup { - action: AssocAction { - class_version: 2, - owning_network: network_handle, - action_index: allocator.variable_actions.len() as i32 + 1, - max_dependency_index: group_dependencies.len() as i32 + 1, - ..Default::default() - }, - version: 2, - flag: false, - // Only world-XY entities reach native graph materialization. - work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], - dependency: Handle::NULL, - actions: group_dependencies, - nodes, - }; - allocator.insert_associative_at( - group_handle, - network_handle, - "ASSOC2DCONSTRAINTGROUP", - "AcDbAssoc2dConstraintGroup", - AssociativeData::ConstraintGroup(group), - ); + if let Some(group_handle) = group_handle { + let group = Assoc2dConstraintGroup { + action: AssocAction { + class_version: 2, + owning_network: network_handle, + action_index: allocator.variable_actions.len() as i32 + 1, + max_dependency_index: group_dependencies.len() as i32 + 1, + ..Default::default() + }, + version: 2, + flag: false, + work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], + dependency: Handle::NULL, + actions: group_dependencies, + nodes, + }; + allocator.insert_associative_at( + group_handle, + network_handle, + "ASSOC2DCONSTRAINTGROUP", + "AcDbAssoc2dConstraintGroup", + AssociativeData::ConstraintGroup(group), + ); + } + + for (offset, handle) in preserved_groups.iter().copied().enumerate() { + if let Some(ObjectType::Associative(object)) = allocator.document.objects.get_mut(&handle) { + object.owner = network_handle; + if let AssociativeData::ConstraintGroup(group) = &mut object.data { + group.action.owning_network = network_handle; + group.action.action_index = allocator.variable_actions.len() as i32 + + i32::from(group_handle.is_some()) + + offset as i32 + + 1; + } + } + } let network = AssocNetwork { action: AssocAction { @@ -1700,12 +2401,15 @@ fn materialize_scope( ..Default::default() }, network_version: 0, - network_action_index: allocator.variable_actions.len() as i32 + 1, + network_action_index: allocator.variable_actions.len() as i32 + + i32::from(group_handle.is_some()) + + preserved_groups.len() as i32, actions: allocator .variable_actions .iter() .copied() - .chain(std::iter::once(group_handle)) + .chain(group_handle) + .chain(preserved_groups) .map(|dependency| AssocActionDependency { is_owned: true, dependency, @@ -1736,37 +2440,118 @@ fn materialize_scope( } impl Scene { - /// Rebuilds every sketch scope's native `AssocNetwork`/ - /// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`, - /// replacing whatever this module wrote on a previous save. - /// - /// `enabled` is the app's `write_dwg_native_constraints` Options toggle - /// (off by default — this graph adds file weight every save, whether or - /// not compatible applications interop is wanted). It only gates *creating* the - /// graph in a scope that doesn't have one yet: a scope that already - /// carries one — from an earlier save with the setting on, or from a - /// file that came from native implementations — keeps getting it rebuilt in sync - /// regardless of the current setting ([`has_native_network`]), so - /// toggling this off never leaves a stale graph sitting in the file. - pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { - let mut scopes = Vec::new(); - let mut preserved_networks = Vec::new(); - for index in 0..self.sketch_constraints.len() { - let set = self.sketch_constraints[index].clone(); + pub(crate) fn load_named_parameters_from_document(&mut self) { + self.named_parameters = ParameterTable::new(); + let mut variables: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Associative(AssociativeObject { + handle, + data: AssociativeData::Variable(variable), + .. + }) => Some((*handle, variable)), + _ => None, + }) + .collect(); + variables.sort_by_key(|(handle, _)| handle.value()); + let model_network = native_scope_network_handle( + &self.document, + self.document.header.model_space_block_handle, + ); + for (_, variable) in variables { + if model_network.is_none_or(|network| variable.action.owning_network != network) { + continue; + } + let source = if variable.expression.trim().is_empty() { + numeric_value(&variable.value).map(|value| value.to_string()) + } else { + Some(variable.expression.clone()) + }; + if let Some(source) = source { + let _ = self.named_parameters.set(&variable.name, &source); + } + } + } + + pub(crate) fn load_parametric_constraints_from_document(&mut self) { + self.parametric_constraints.clear(); + let owners: Vec<_> = self + .document + .block_records + .iter() + .map(|record| record.handle) + .collect(); + for owner in owners { + if let Some(set) = + native_constraint_set(&self.document, owner, &mut self.named_parameters) + { + self.parametric_constraints.push(set); + } + } + } + + /// Makes the standard associative graph match the live command model. + pub(crate) fn sync_native_parametric_graph(&mut self) { + let mut owners: Vec<_> = self + .document + .block_records + .iter() + .map(|record| record.handle) + .collect(); + for set in &self.parametric_constraints { let owner = set.scope.owner_handle(&self.document); - if owner.is_null() { - continue; + if !owner.is_null() && !owners.contains(&owner) { + owners.push(owner); } - if !enabled && !has_native_network(&self.document, owner) { - continue; - } - if native_group_requires_preservation(&self.document, owner) { - if let Some(network) = native_scope_network_handle(&self.document, owner) { - preserved_networks.push(network); + } + let model_owner = self.document.header.model_space_block_handle; + if !owners.contains(&model_owner) { + owners.push(model_owner); + } + + let mut scopes = Vec::new(); + for owner in owners { + let existing = native_scope_network_handle(&self.document, owner).is_some(); + let runtime = self + .parametric_constraints + .iter() + .find(|set| set.scope.owner_handle(&self.document) == owner) + .cloned(); + let set = match runtime { + Some(set) => Some(set), + None if existing => { + native_constraint_set(&self.document, owner, &mut self.named_parameters) } + None => None, + }; + if set.is_none() + && !existing + && (owner != model_owner || self.named_parameters.is_empty()) + { continue; } - scopes.push((owner, set)); + if let Some(set) = &set { + sync_retained_group_constraints(&mut self.document, set); + } + let (variables, groups) = detach_preserved_scope(&mut self.document, owner); + let mut set = set.unwrap_or_else(|| { + if owner == model_owner { + ParametricConstraintSet::new(ParametricScope::ModelSpace) + } else { + ParametricConstraintSet::new(ParametricScope::Block(owner)) + } + }); + set.constraints.retain(|constraint| { + constraint + .native_origin + .is_none_or(|origin| !groups.contains(&origin.group)) + }); + for constraint in &mut set.constraints { + constraint.native_origin = None; + } + scopes.push((owner, set, variables, groups)); } if scopes.is_empty() { return; @@ -1780,15 +2565,23 @@ impl Scene { NETWORK_DICTIONARY_KEY, ); let root_network_handle = self.document.allocate_handle(); - let mut child_networks = preserved_networks; - for (index, (owner, set)) in scopes.into_iter().enumerate() { + let mut child_networks = Vec::new(); + for (index, (owner, set, variables, groups)) in scopes.into_iter().enumerate() { + let parameters = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; if let Some(handle) = materialize_scope( &mut self.document, owner, &set, - &self.named_parameters, + parameters, root_network_handle, index as i32 + 1, + variables, + groups, + owner == model_owner, ) { child_networks.push(handle); } @@ -1835,9 +2628,12 @@ impl Scene { #[cfg(test)] mod tests { - use super::super::sketch_constraints::SketchScope; + use super::super::parametric_constraints::{ + angle_sector, distance_direction_type, ParametricScope, + }; use super::*; - use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline}; + use crate::scene::ChangeKind; + use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline, Ray, Spline, XLine}; use acadrust::tables::BlockRecord; use acadrust::types::Vector2; @@ -1919,13 +2715,21 @@ mod tests { let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(b)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; // Root + two geometry nodes + two datums + two constraints. let before = native_group(&scene.document, owner); @@ -1984,35 +2788,257 @@ mod tests { } } + #[test] + fn ray_and_construction_line_geometry_round_trip_through_the_standard_graph() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let ray = scene.add_entity(EntityType::Ray(Ray::new( + Vector3::ZERO, + Vector3::new(1.0, 1.0, 0.0), + ))); + let xline = scene.add_entity(EntityType::XLine(XLine::new( + Vector3::new(4.0, 0.0, 0.0), + Vector3::UNIT_Y, + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(ray)], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(ray), ParametricRef::whole(xline)], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("line_kinds.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + assert!(group.nodes.iter().any(|node| { + matches!( + node.data, + AssocConstraintNodeData::BoundedLine { is_ray: true, .. } + ) + })); + assert!(group.nodes.iter().any(|node| { + node.class_name == "AcConstrainedLine" + && matches!(node.data, AssocConstraintNodeData::Line { .. }) + })); + + restored.load_parametric_constraints_from_document(); + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("constraints should import"); + assert!(set + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Horizontal)); + assert!(set + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Parallel)); + } + } + + #[test] + fn implicit_midpoint_round_trips_as_standard_implied_relations() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let carrier = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let attached = line_entity(&mut scene, (5.0, 0.0), (5.0, 4.0)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(carrier, -2), + ParametricRef::point(attached, 0), + ], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("midpoint.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + + assert!(group.nodes.iter().any(|node| matches!( + node.data, + AssocConstraintNodeData::ImplicitPoint { + point_type: implicit_point_type::MID, + .. + } + ))); + for class_name in ["AcPointCurveConstraint", "AcMidPointConstraint"] { + assert!(group.nodes.iter().any(|node| { + node.class_name == class_name + && matches!( + node.data, + AssocConstraintNodeData::Geometrical { + is_implied: true, + is_active: true, + .. + } + ) + })); + } + + restored.load_parametric_constraints_from_document(); + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("coincidence should import"); + assert_eq!(set.constraints.len(), 1); + assert_eq!(set.constraints[0].kind, ConstraintKind::Coincident); + assert!(set.constraints[0] + .refs + .iter() + .any(|reference| reference.entity == carrier && reference.marker == Some(-2))); + } + } + + #[test] + fn spline_smoothness_round_trips_as_a_standard_composite_graph() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let first = scene.add_entity(EntityType::Spline(Spline::from_control_points( + 3, + vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(4.0, 2.0, 0.0), + Vector3::new(6.0, 2.0, 0.0), + ], + ))); + let second = line_entity(&mut scene, (6.0, 2.0), (12.0, 2.0)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Smooth, + vec![ + ParametricRef::point(first, 1), + ParametricRef::point(second, 0), + ], + None, + ); + scene.sync_native_parametric_graph(); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save {ext}: {error}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("smooth.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + let owner = restored.document.header.model_space_block_handle; + let group = native_group(&restored.document, owner); + + let spline_nodes: Vec<_> = group + .nodes + .iter() + .filter_map(|node| match &node.data { + AssocConstraintNodeData::Spline { + implicit_point_ids, .. + } => Some(implicit_point_ids), + _ => None, + }) + .collect(); + assert_eq!(spline_nodes.len(), 1, "{ext}: spline graph node is missing"); + assert!(spline_nodes.iter().all(|points| points.len() == 4)); + assert_eq!( + group + .nodes + .iter() + .filter(|node| matches!( + node.data, + AssocConstraintNodeData::HelpParameter { .. } + )) + .count(), + 2, + "{ext}: the spline side needs one parameter for each child" + ); + let composite = group + .nodes + .iter() + .find(|node| node.class_name == "AcG2SmoothConstraint") + .expect("smooth composite should exist"); + let AssocConstraintNodeData::Composite { + owned_constraint_ids, + .. + } = &composite.data + else { + panic!("{ext}: smooth node should carry composite data"); + }; + assert_eq!(owned_constraint_ids.len(), 3); + assert!(owned_constraint_ids.iter().all(|child_id| { + group.nodes.iter().any(|child| { + child.node_id == *child_id + && owning_composite(&child.data) == Some(composite.node_id) + }) + })); + + restored.load_parametric_constraints_from_document(); + let constraints = &restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("smooth constraint should import") + .constraints; + assert_eq!( + constraints.len(), + 1, + "{ext}: composite children stay internal" + ); + assert_eq!(constraints[0].kind, ConstraintKind::Smooth); + assert_eq!( + constraints[0].refs, + vec![ + ParametricRef::point(first, 1), + ParametricRef::point(second, 0) + ] + ); + } + } + #[test] fn native_only_constraints_and_parameters_are_imported() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let b = line_entity(&mut scene, (0.0, 5.0), (10.0, 5.0)); scene.named_parameters.set("gap", "5").unwrap(); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); set.add( ConstraintKind::DistanceY, - vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], Some(DrivingValue::Named("gap".to_string())), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save native-only graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("native_only.dwg", bytes).unwrap(); restored.load_named_parameters_from_document(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let set = restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native group should become an editable constraint set"); let kinds: Vec<_> = set .constraints @@ -2034,6 +3060,158 @@ mod tests { ); } + #[test] + fn equal_parameter_names_remain_independent_across_block_scopes() { + let mut scene = Scene::new(); + let mut block_handles = Vec::new(); + let mut line_handles = Vec::new(); + for (index, length) in [5.0, 9.0].into_iter().enumerate() { + let mut block = BlockRecord::new(format!("fixture_{index}")); + block.handle = scene.document.allocate_handle(); + let block_handle = block.handle; + scene.document.block_records.add(block).unwrap(); + + let mut line = Line::from_points(Vector3::ZERO, Vector3::new(length, 0.0, 0.0)); + line.common.owner_handle = block_handle; + let line_handle = scene.document.add_entity(EntityType::Line(line)).unwrap(); + let set = scene.parametric_constraint_set_mut(ParametricScope::Block(block_handle)); + set.local_parameters + .set("width", &length.to_string()) + .unwrap(); + set.add( + ConstraintKind::DistanceX, + vec![ + ParametricRef::point(line_handle, 0), + ParametricRef::point(line_handle, 1), + ], + Some(DrivingValue::Named("width".to_string())), + ); + block_handles.push(block_handle); + line_handles.push(line_handle); + } + + scene.sync_native_parametric_graph(); + let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) + .expect("save block parameter scopes"); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes("block_parameters.dwg", bytes).unwrap(); + restored.load_named_parameters_from_document(); + restored.load_parametric_constraints_from_document(); + + assert!(restored.named_parameters.is_empty()); + for (index, block_handle) in block_handles.into_iter().enumerate() { + let set = restored + .parametric_constraint_set(ParametricScope::Block(block_handle)) + .expect("block constraint scope should survive"); + assert_eq!(set.local_parameters.resolve("width"), Ok([5.0, 9.0][index])); + assert_eq!( + set.constraints[0].driving_param, + Some(DrivingValue::Named("width".to_string())) + ); + } + + let changes: Vec<_> = line_handles + .into_iter() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); + restored.refresh_parametric_constraints(&changes); + } + + #[test] + fn standalone_named_parameters_round_trip_as_standard_variables() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let xrecords_before = scene + .document + .objects + .values() + .filter(|object| matches!(object, ObjectType::XRecord(_))) + .count(); + scene.named_parameters.set("width", "12").unwrap(); + scene.named_parameters.set("height", "width / 2").unwrap(); + scene.sync_native_parametric_graph(); + + assert_eq!( + scene + .document + .objects + .values() + .filter(|object| matches!(object, ObjectType::XRecord(_))) + .count(), + xrecords_before, + "standard parameters must not create application records" + ); + assert!(has_variable_named(&scene.document, "width")); + assert!(has_variable_named(&scene.document, "height")); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("parameters.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + restored.load_named_parameters_from_document(); + + assert_eq!(restored.named_parameters.resolve("width"), Ok(12.0)); + assert_eq!(restored.named_parameters.resolve("height"), Ok(6.0)); + } + } + + #[test] + fn command_style_add_and_remove_sync_the_standard_graph_immediately() { + let mut scene = Scene::new(); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let id = scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(line)], + None, + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let owner = scene.document.header.model_space_block_handle; + assert!(native_group(&scene.document, owner) + .nodes + .iter() + .any(|node| node.class_name == "AcHorizontalConstraint")); + + assert!(scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .remove(id)); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + assert!(native_scope_network_handle(&scene.document, owner).is_none()); + } + + #[test] + fn inactive_constraints_round_trip_without_becoming_active() { + for ext in ["dwg", "dxf"] { + let mut scene = Scene::new(); + let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(line)], + None, + ); + set.constraints[0].enabled = false; + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|e| panic!("save to {ext}: {e}")); + let mut restored = Scene::new(); + restored.document = crate::io::load_bytes(&format!("inactive.{ext}"), bytes) + .unwrap_or_else(|e| panic!("reload {ext}: {e}")); + restored.load_parametric_constraints_from_document(); + + let constraint = &restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("constraint set should survive") + .constraints[0]; + assert_eq!(constraint.kind, ConstraintKind::Horizontal); + assert!(!constraint.enabled); + } + } + #[test] fn point_and_two_point_horizontal_constraints_round_trip_natively() { let mut scene = Scene::new(); @@ -2048,27 +3226,30 @@ mod tests { Vector3::new(3.0, 4.0, 0.0), ))); let line = line_entity(&mut scene, (5.0, 7.0), (10.0, 9.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Horizontal, - vec![SketchRef::point(point, 0), SketchRef::point(insert, 0)], + vec![ + ParametricRef::point(point, 0), + ParametricRef::point(insert, 0), + ], None, ); set.add( ConstraintKind::Midpoint, - vec![SketchRef::point(point, 0), SketchRef::whole(line)], + vec![ParametricRef::point(point, 0), ParametricRef::whole(line)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save point graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("point_constraints.dwg", bytes).unwrap(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let constraints = &restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native point constraints should be imported") .constraints; assert_eq!(constraints.len(), 2); @@ -2086,28 +3267,28 @@ mod tests { Vector2::new(10.0, 7.0), ]))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 1)], + vec![ParametricRef::segment(polyline, 1)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version) .expect("save polyline segment graph"); let mut restored = Scene::new(); restored.document = crate::io::load_bytes("polyline_segment.dwg", bytes).unwrap(); - restored.load_sketch_constraints_from_document(); + restored.load_parametric_constraints_from_document(); let set = restored - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("native segment constraint should be imported"); assert_eq!(set.constraints.len(), 1); assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); assert_eq!( set.constraints[0].refs, - vec![SketchRef::segment(polyline, 1)] + vec![ParametricRef::segment(polyline, 1)] ); } @@ -2120,10 +3301,10 @@ mod tests { .set("gap", "5") .expect("defining the parameter should succeed"); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], Some(DrivingValue::Named("gap".to_string())), ); let owner = scene.document.header.model_space_block_handle; @@ -2140,14 +3321,10 @@ mod tests { }) } - /// DWG is the format this module's dependency chain (`AssocGeomDependency` - /// / `AssocValueDependency` / `AssocVariable`) actually survives a save - /// through — see [`a_dxf_save_keeps_only_the_constraint_group_shell`] for - /// the DXF side of this same scene and why it's different, not a bug here. #[test] fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); assert!( has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization" @@ -2234,11 +3411,10 @@ mod tests { assert!(network.actions.iter().all(|action| action.is_owned)); } - /// DXF currently keeps the graph shell but omits dependency objects. #[test] - fn a_dxf_save_keeps_only_the_constraint_group_shell() { + fn a_dxf_save_keeps_the_full_constraint_dependency_chain() { let (mut scene, owner) = distance_with_named_parameter_scene(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version) .unwrap_or_else(|e| panic!("save to dxf: {e}")); @@ -2251,7 +3427,7 @@ mod tests { .nodes .iter() .any(|n| n.class_name == "AcDistanceConstraint"), - "expected the distance constraint node's shell to survive, got {:?}", + "expected the distance constraint node to survive, got {:?}", group .nodes .iter() @@ -2259,8 +3435,8 @@ mod tests { .collect::>() ); assert!( - !has_variable_named(&reloaded, "gap"), - "acadrust's DXF writer should not carry AssocVariable; update the native materializer if this changes" + has_variable_named(&reloaded, "gap"), + "expected the named variable to survive a DXF round trip" ); } @@ -2269,12 +3445,16 @@ mod tests { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let first_count = scene.document.objects.len(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let second_count = scene.document.objects.len(); assert_eq!( first_count, second_count, @@ -2283,13 +3463,17 @@ mod tests { } #[test] - fn resaving_preserves_a_native_group_with_uneditable_geometry() { + fn a_standard_rigid_set_and_its_constraints_remain_editable() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let group_handle = native_group_handle(&scene.document, owner); @@ -2300,40 +3484,103 @@ mod tests { else { panic!("expected a native constraint group"); }; + let geometry_id = group + .nodes + .iter() + .find(|node| node.class_name == "AcConstrainedBoundedLine") + .map(|node| node.node_id) + .expect("line geometry should exist"); + for node in &mut group.nodes { + if node.node_id == geometry_id { + if let AssocConstraintNodeData::BoundedLine { + geometry_node_id, .. + } = &mut node.data + { + *geometry_node_id = 99; + } + } + } group.nodes.push(AssocConstraintNode { node_id: 99, class_name: "AcConstrainedRigidSet".to_string(), data: AssocConstraintNodeData::RigidSet { geometry_dependency: Handle::NULL, - geometry_node_id: 99, + geometry_node_id: 0, reserved: false, - transform: [0.0; 16], - geometry_ids: vec![1], + transform: [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + geometry_ids: vec![geometry_id], }, ..Default::default() }); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + scene.load_parametric_constraints_from_document(); + let imported = scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("standard group should be imported"); + assert!(imported + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::Horizontal)); + assert!(imported + .constraints + .iter() + .any(|constraint| constraint.kind == ConstraintKind::RigidSet)); - let group = native_group(&scene.document, owner); - assert!(group + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(a)], + None, + ); + scene.sync_native_parametric_graph(); + + let groups: Vec<_> = native_group_handles(&scene.document, owner) + .into_iter() + .filter_map(|handle| match scene.document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) => Some(group), + _ => None, + }) + .collect(); + assert!(groups.iter().any(|group| group .nodes .iter() - .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. }))); - assert!(!group + .any(|node| matches!(node.data, AssocConstraintNodeData::RigidSet { .. })))); + assert!(groups.iter().any(|group| group .nodes .iter() - .any(|node| node.class_name == "AcVerticalConstraint")); + .any(|node| node.class_name == "AcVerticalConstraint"))); + + let horizontal = scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .constraints + .iter_mut() + .find(|constraint| constraint.kind == ConstraintKind::Horizontal) + .expect("imported horizontal constraint"); + horizontal.enabled = false; + scene.sync_native_parametric_graph(); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = scene.document.objects.get(&group_handle) + else { + panic!("retained group should exist"); + }; + assert!(group.nodes.iter().any(|node| { + node.class_name == "AcHorizontalConstraint" && !constraint_is_active(&node.data) + })); } #[test] fn an_empty_constraint_set_leaves_no_native_network() { let mut scene = Scene::new(); - let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_dwg_native_constraints_for_save(true); + let _ = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let dict = scene.document.extension_dictionary_handle(owner); @@ -2348,77 +3595,29 @@ mod tests { } fn model_space_has_native_network(scene: &Scene) -> bool { - has_native_network( + native_scope_network_handle( &scene.document, scene.document.header.model_space_block_handle, ) + .is_some() } #[test] - fn the_setting_off_skips_a_scope_with_no_existing_native_graph() { + fn sync_creates_the_standard_graph_for_a_new_constraint() { let mut scene = Scene::new(); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); - scene.materialize_dwg_native_constraints_for_save(false); - assert!( - !model_space_has_native_network(&scene), - "a scope with no prior native graph should stay untouched while the setting is off" - ); - } - - /// The "sync-if-present, create-only-if-enabled" rule from - /// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment: - /// once a scope has the native graph (here, from an earlier save with - /// the setting on), turning the setting off must not freeze that graph - /// out of date — it should keep tracking the current constraints. - #[test] - fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() { - let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); assert!( model_space_has_native_network(&scene), - "sanity: the graph should exist after the first, enabled save" - ); - - let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(b)], None); - scene.materialize_dwg_native_constraints_for_save(false); - - let owner = scene.document.header.model_space_block_handle; - let group = native_group(&scene.document, owner); - assert!( - group.nodes.iter().any(|n| n.class_name == "AcVerticalConstraint"), - "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", - group.nodes.iter().map(|n| &n.class_name).collect::>() - ); - } - - /// The other half of the same rule: disabling the setting after a scope - /// already has the graph must not make that graph disappear either — - /// only remove it by clearing the scope's constraints (existing - /// `an_empty_constraint_set_leaves_no_native_network` behavior), not by - /// toggling the setting off. - #[test] - fn the_setting_off_does_not_remove_an_existing_native_graph() { - let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_dwg_native_constraints_for_save(true); - scene.materialize_dwg_native_constraints_for_save(false); - assert!( - model_space_has_native_network(&scene), - "an existing native graph must survive a disabled-setting resave, not be silently dropped" + "a new constraint should immediately create its standard graph" ); } @@ -2439,59 +3638,75 @@ mod tests { let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0)); let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], + vec![ + ParametricRef::center(circle_a), + ParametricRef::center(circle_b), + ], None, ); set.add( ConstraintKind::Colinear, - vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], + vec![ParametricRef::whole(line_a), ParametricRef::whole(line_b)], + None, + ); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(line_a)], None, ); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None); set.add( ConstraintKind::CenterPoint, - vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::center(circle_a), + ], None, ); set.add( ConstraintKind::Midpoint, - vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], + vec![ + ParametricRef::point(marker, 1), + ParametricRef::whole(line_a), + ], None, ); set.add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(circle_a), + ], None, ); set.add( ConstraintKind::EqualDistance, vec![ - SketchRef::point(line_b, 0), - SketchRef::point(line_b, 1), - SketchRef::point(line_a, 0), - SketchRef::point(line_a, 1), + ParametricRef::point(line_b, 0), + ParametricRef::point(line_b, 1), + ParametricRef::point(line_a, 0), + ParametricRef::point(line_a, 1), ], None, ); set.add( ConstraintKind::Symmetric, vec![ - SketchRef::center(circle_a), - SketchRef::center(circle_b), - SketchRef::whole(axis), + ParametricRef::center(circle_a), + ParametricRef::center(circle_b), + ParametricRef::whole(axis), ], None, ); set.add( ConstraintKind::Normal, - vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], + vec![ParametricRef::whole(circle_a), ParametricRef::whole(line_a)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2523,7 +3738,7 @@ mod tests { } /// Constraint-parity Phase 1: an Arc registers in the solver as its own - /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and + /// center/radius (`parametric_solve.rs`'s `EntityGeom::Circle` reuse), and /// this module already had constrained-arc geometry-dependency /// support in place before that. This confirms the two sides actually /// meet: a Concentric/Tangent/Radius constraint referencing an arc @@ -2537,19 +3752,19 @@ mod tests { let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(arc), SketchRef::center(circle)], + vec![ParametricRef::center(arc), ParametricRef::center(circle)], None, ); set.add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2580,7 +3795,7 @@ mod tests { /// Constraint-parity Phase 4b: a Coincident constraint referencing an /// arc's actual *endpoint* (marker 0, not its center via `-3` or its - /// whole curve) round-trips correctly too — `SketchRef`'s marker is + /// whole curve) round-trips correctly too — `ParametricRef`'s marker is /// serialized generically regardless of what entity type or point it /// addresses, so this needs no dedicated DWG-side wiring beyond what /// already existed; this test is here to actually confirm that rather @@ -2593,14 +3808,14 @@ mod tests { let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + vec![ParametricRef::point(arc, 0), ParametricRef::point(line, 0)], None, ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2638,16 +3853,22 @@ mod tests { )); let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], + None, + ); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(circle)], None, ); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None); - scene.materialize_sketch_constraints_for_save(); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2673,85 +3894,16 @@ mod tests { let mut reloaded_scene = Scene::new(); reloaded_scene.document = reloaded_doc; - reloaded_scene.load_sketch_constraints_from_document(); + reloaded_scene.load_parametric_constraints_from_document(); let restored = reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap_or_else(|| { panic!("{ext}: no ModelSpace constraint set survived the round trip") }); let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); assert!( kinds.contains(&ConstraintKind::Concentric), - "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}" - ); - } - } - - /// Constraint-parity Phase 4a: `ArcLength` has no native SDK class at - /// all (confirmed against `AcExplicitConstr.h`) — this asserts the - /// degradation is exactly as documented: the native DWG/DXF graph - /// gets every *other* constraint on the arc (Radius, here) but no - /// ArcLength-shaped node, while the app's own constraint set (what - /// XRecord persistence actually serializes, per `sketch_persist.rs`'s - /// fully-generic encode/decode) still carries it untouched. - #[test] - fn arc_length_has_no_native_class_but_survives_in_the_apps_own_constraint_set() { - for ext in ["dxf", "dwg"] { - let mut scene = Scene::new(); - let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0); - - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(4.0)), - ); - set.add( - ConstraintKind::ArcLength, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(6.0)), - ); - - // Both materializers, "called alongside, not instead of" each - // other (`materialize_dwg_native_constraints_for_save`'s own - // doc comment) — the native graph and this app's own XRecord - // format are independent, additive persistence paths. - scene.materialize_sketch_constraints_for_save(); - scene.materialize_dwg_native_constraints_for_save(true); - let owner = scene.document.header.model_space_block_handle; - - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext}: {e}")); - let reloaded_doc = crate::io::load_bytes(&format!("arc_length.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext}: {e}")); - - let group = native_group(&reloaded_doc, owner); - let class_names: Vec<&str> = - group.nodes.iter().map(|n| n.class_name.as_str()).collect(); - assert!( - class_names.contains(&"AcRadiusDiameterConstraint"), - "{ext}: missing radius/diameter constraint, got {class_names:?}" - ); - assert!( - !class_names.iter().any(|n| n.contains("ARCLENGTH")), - "{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}" - ); - - // Still present in this app's own (XRecord-backed) constraint - // set — reload into a fresh `Scene` and read it back the same - // way `sketch_persist.rs`'s own round-trip tests do. - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = reloaded_doc; - reloaded_scene.load_sketch_constraints_from_document(); - let restored = reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap_or_else(|| { - panic!("{ext}: no ModelSpace constraint set survived the round trip") - }); - let kinds: Vec = restored.constraints.iter().map(|c| c.kind).collect(); - assert!( - kinds.contains(&ConstraintKind::ArcLength), - "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}" + "{ext}: the ellipse constraint should round-trip through the associative graph, got {kinds:?}" ); } } @@ -2770,24 +3922,24 @@ mod tests { let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0); let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Diameter, - vec![SketchRef::whole(circle)], + vec![ParametricRef::whole(circle)], Some(DrivingValue::Literal(16.0)), ); set.add( ConstraintKind::DistanceX, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(10.0)), ); set.add( ConstraintKind::DistanceY, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(3.0)), ); - scene.materialize_dwg_native_constraints_for_save(true); + scene.sync_native_parametric_graph(); let owner = scene.document.header.model_space_block_handle; let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) @@ -2833,4 +3985,128 @@ mod tests { ); } } + + #[test] + fn line_relative_distance_and_angle_sectors_round_trip() { + for ext in ["dxf", "dwg"] { + let mut scene = Scene::new(); + let measured = line_entity(&mut scene, (0.0, 0.0), (3.0, 4.0)); + let direction = line_entity(&mut scene, (-2.0, -2.0), (2.0, 2.0)); + + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + let distance_id = set.add( + ConstraintKind::DistanceDirected, + vec![ + ParametricRef::point(measured, 0), + ParametricRef::point(measured, 1), + ParametricRef::whole(direction), + ], + Some(DrivingValue::Literal(7.0)), + ); + let distance = set + .constraints + .iter_mut() + .find(|constraint| constraint.id == distance_id) + .unwrap(); + distance.distance_direction_type = distance_direction_type::PARALLEL_TO_LINE; + distance.distance_direction = Some(Vector3::new( + std::f64::consts::FRAC_1_SQRT_2, + std::f64::consts::FRAC_1_SQRT_2, + 0.0, + )); + + let angle_id = set.add( + ConstraintKind::Angle, + vec![ + ParametricRef::whole(measured), + ParametricRef::whole(direction), + ], + Some(DrivingValue::Literal(30.0)), + ); + set.constraints + .iter_mut() + .find(|constraint| constraint.id == angle_id) + .unwrap() + .angle_sector = angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE; + + let angle3_id = set.add( + ConstraintKind::Angle3Point, + vec![ + ParametricRef::point(measured, 0), + ParametricRef::point(measured, 1), + ParametricRef::point(direction, 1), + ], + Some(DrivingValue::Literal(45.0)), + ); + set.constraints + .iter_mut() + .find(|constraint| constraint.id == angle3_id) + .unwrap() + .angle_sector = angle_sector::ANTIPARALLEL_CLOCKWISE; + + scene.sync_native_parametric_graph(); + let owner = scene.document.header.model_space_block_handle; + let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) + .unwrap_or_else(|error| panic!("save to {ext}: {error}")); + let document = crate::io::load_bytes(&format!("directed.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + + let group = native_group(&document, owner); + assert!(group.nodes.iter().any(|node| matches!( + node.data, + AssocConstraintNodeData::Distance { + direction_type: distance_direction_type::PARALLEL_TO_LINE, + .. + } + ))); + let mut sectors: Vec<_> = group + .nodes + .iter() + .filter_map(|node| match node.data { + AssocConstraintNodeData::Angle { sector_type, .. } => Some(sector_type), + _ => None, + }) + .collect(); + sectors.sort_unstable(); + assert_eq!( + sectors, + vec![ + angle_sector::ANTIPARALLEL_CLOCKWISE, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE, + ] + ); + + let mut restored = Scene::new(); + restored.document = document; + restored.load_named_parameters_from_document(); + restored.load_parametric_constraints_from_document(); + let restored = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap(); + let distance = restored + .constraints + .iter() + .find(|constraint| constraint.kind == ConstraintKind::DistanceDirected) + .unwrap(); + assert_eq!( + distance.distance_direction_type, + distance_direction_type::PARALLEL_TO_LINE + ); + assert_eq!(distance.refs.len(), 3); + let mut restored_sectors: Vec<_> = restored + .constraints + .iter() + .filter(|constraint| { + matches!( + constraint.kind, + ConstraintKind::Angle | ConstraintKind::Angle3Point + ) + }) + .map(|constraint| constraint.angle_sector) + .collect(); + restored_sectors.sort_unstable(); + assert_eq!(restored_sectors, sectors); + } + } + } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 26d122d7f..dc42e7636 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -146,8 +146,7 @@ impl Scene { fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { use acadrust::entities::ExtendedEntityData; use acadrust::objects::{ - ClassObject, ClassObjectData, SectionManager, SectionSettings, - SectionTypeSettings, + ClassObject, ClassObjectData, SectionManager, SectionSettings, SectionTypeSettings, }; let EntityType::Extended(extended) = entity else { @@ -169,16 +168,15 @@ impl Scene { ); if !valid_settings { let settings_handle = self.document.allocate_handle(); - let mut settings = ClassObject::new(ClassObjectData::SectionSettings( - SectionSettings { + let mut settings = + ClassObject::new(ClassObjectData::SectionSettings(SectionSettings { current_type: 4, types: vec![SectionTypeSettings { section_type: 4, generation: 17, ..SectionTypeSettings::default() }], - }, - )); + })); settings.handle = settings_handle; settings.owner = entity_handle; self.document @@ -223,9 +221,9 @@ impl Scene { if let Some(ObjectType::Dictionary(dictionary)) = self.document.objects.get_mut(&root) { - dictionary.entries.retain(|(name, _)| { - !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER") - }); + dictionary + .entries + .retain(|(name, _)| !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")); dictionary.add_entry("ACAD_SECTION_MANAGER", handle); } handle @@ -256,10 +254,7 @@ impl Scene { let mut needs_geometry_bump = false; for entity in entities { - let affects_blocks = matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); let handle = self.add_entity_internal(entity, false); handles.push(handle); if handle.is_null() { @@ -285,10 +280,7 @@ impl Scene { // Only block sentinels mutate a block definition and require rebuilding // the block cache. A top-level INSERT merely references an existing // definition, so adding it can patch just that new render handle. - let affects_blocks = matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // INSERT invalidates rendered block instances, but it does not mutate // the referenced block definition. Only block sentinels require a // structure image for undo; ordinary owner membership is intrinsic add @@ -363,8 +355,9 @@ impl Scene { // Delta-undo poison inputs (captured before the mutations below): an // add that also creates a new layer, adds a block, or inserts an image // definition mutates non-entity state a pure-entity delta can't undo. - let creates_layer = - self.is_recording_undo() && !layer.trim().is_empty() && !self.document.layers.contains(&layer); + let creates_layer = self.is_recording_undo() + && !layer.trim().is_empty() + && !self.document.layers.contains(&layer); self.ensure_layer(&layer); let app_ids: Vec = entity .common() @@ -374,9 +367,9 @@ impl Scene { .map(|record| record.application_name.clone()) .collect(); let creates_app_id = self.is_recording_undo() - && app_ids.iter().any(|name| { - !name.trim().is_empty() && !self.document.app_ids.contains(name) - }); + && app_ids + .iter() + .any(|name| !name.trim().is_empty() && !self.document.app_ids.contains(name)); for name in &app_ids { self.ensure_app_id(name); } @@ -412,14 +405,13 @@ impl Scene { if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; - let material = - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + let material = crate::scene::model::material_model::resolve_material_with_base( + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); material.apply_to_with_face_overrides( &mut model, &self.document, @@ -539,13 +531,8 @@ impl Scene { // Replacing (or becoming) a block sentinel forces a full block-cache // rebuild. INSERT edits (including retargeting to another existing // definition) only change that top-level render handle. - let affects_blocks = matches!( - existing, - EntityType::Block(_) | EntityType::BlockEnd(_) - ) || matches!( - &entity, - EntityType::Block(_) | EntityType::BlockEnd(_) - ); + let affects_blocks = matches!(existing, EntityType::Block(_) | EntityType::BlockEnd(_)) + || matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); // A plugin edit may retarget the entity to a novel layer; register it // so the edited entity keeps that layer on save instead of collapsing @@ -627,14 +614,13 @@ impl Scene { if let Some(mut model) = mesh_seed { if let Some(entity) = self.document.get_entity(handle) { let color = self.render_style(entity).0; - let material = - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + let material = crate::scene::model::material_model::resolve_material_with_base( + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); material.apply_to_with_face_overrides( &mut model, &self.document, @@ -770,15 +756,14 @@ impl Scene { _ => None, }) .collect(); - let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = - mesh_entities - .into_iter() - .map(|(handle, entity)| { - let color = self.render_style(entity.as_ref()).0; - let top_level = layout_blocks.contains(&entity.common().owner_handle); - (handle, entity, color, top_level) - }) - .collect(); + let entries: Vec<(Handle, std::sync::Arc, [f32; 4], bool)> = mesh_entities + .into_iter() + .map(|(handle, entity)| { + let color = self.render_style(entity.as_ref()).0; + let top_level = layout_blocks.contains(&entity.common().owner_handle); + (handle, entity, color, top_level) + }) + .collect(); let facet_res = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_res); @@ -1119,16 +1104,13 @@ impl Scene { self.belongs_to_visible_block(handle, c.owner_handle, target_block) }) .flat_map(|(&handle, model)| { - let contextual = self - .document - .get_entity(handle) - .map(|entity| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - annotation_scale_handle, - ) - }); + let contextual = self.document.get_entity(handle).map(|entity| { + crate::scene::annotative::entity_for_annotation_context( + &self.document, + entity, + annotation_scale_handle, + ) + }); let entity = contextual.as_deref(); let mut m = match entity { Some(EntityType::Hatch(dxf)) @@ -1196,10 +1178,7 @@ impl Scene { ), index, ), - other => ( - crate::scene::convert::tess_util::aci_to_rgba(&other), - 0, - ), + other => (crate::scene::convert::tess_util::aci_to_rgba(&other), 0), }; b.color = bg_color; b.aci = bg_aci; @@ -1218,9 +1197,7 @@ impl Scene { m.scale = dxf.pattern_scale as f32; } model::hatch_model::HatchPattern::Gradient { - angle_deg, - shift, - .. + angle_deg, shift, .. } => { *angle_deg = dxf.gradient_color.angle.to_degrees() as f32; *shift = dxf.gradient_color.shift as f32; @@ -1351,7 +1328,10 @@ impl Scene { .iter() .copied() .filter(|&handle| { - matches!(self.document.get_entity(handle), Some(EntityType::Insert(_))) + matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) }) .collect(); if targets.is_empty() { @@ -1421,8 +1401,7 @@ impl Scene { return true; } if self.object_isolation.hides(common.handle) - || (!include_preview_hidden - && self.preview_hidden.contains(&common.handle)) + || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) { return false; } @@ -1462,10 +1441,7 @@ impl Scene { } let style = context.style_for(&self.document, entity); let preserve_white_mask = source_hatch.is_solid - && matches!( - source_hatch.common.color, - acadrust::types::Color::Index(7) - ); + && matches!(source_hatch.common.color, acadrust::types::Color::Index(7)); let color = if preserve_white_mask { style.0 } else { @@ -1504,9 +1480,15 @@ impl Scene { } let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let key = ( source_hatch.common.handle.value(), @@ -1525,12 +1507,10 @@ impl Scene { let source_id = *hatch_sources .entry(key) .or_insert_with(crate::scene::model::instance_model::next_source_id); - model.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { - source_id, - translation: [matrix[0][3], matrix[1][3], matrix[2][3]], - }, - ); + model.render_instance = Some(crate::scene::model::instance_model::RenderInstance { + source_id, + translation: [matrix[0][3], matrix[1][3], matrix[2][3]], + }); models.push(model); }, ); @@ -1598,48 +1578,37 @@ impl Scene { }; let mut wipeout = source.clone(); if context.is_instanced() { - wipeout.insertion_point = - context.transform.apply(source.insertion_point); - wipeout.u_vector = - context.transform.apply_rotation(source.u_vector); - wipeout.v_vector = - context.transform.apply_rotation(source.v_vector); + wipeout.insertion_point = context.transform.apply(source.insertion_point); + wipeout.u_vector = context.transform.apply_rotation(source.u_vector); + wipeout.v_vector = context.transform.apply_rotation(source.v_vector); } let Some(mut fill_plane) = Self::wipeout_fill_plane(&wipeout) else { return; }; - let (world_origin, mut boundary) = - Self::wipeout_boundary_2d(&wipeout); + let (world_origin, mut boundary) = Self::wipeout_boundary_2d(&wipeout); for clip in &context.clips { let clip: Vec<[f32; 2]> = clip .iter() .map(|point| [point[0] as f32, point[1] as f32]) .collect(); - boundary = pick::xclip::clip_hatch_boundary( - &boundary, - world_origin, - &clip, - ); + boundary = pick::xclip::clip_hatch_boundary(&boundary, world_origin, &clip); } if boundary.len() < 3 { return; } if !context.clips.is_empty() { - let Some(local) = Self::wipeout_boundary_at_xy( - fill_plane.0, - world_origin, - &boundary, - ) else { + let Some(local) = + Self::wipeout_boundary_at_xy(fill_plane.0, world_origin, &boundary) + else { return; }; fill_plane.1 = local; } - let selection_handle = - if tint_insert_selection && context.is_instanced() { - context.root_handle - } else { - source.common.handle - }; + let selection_handle = if tint_insert_selection && context.is_instanced() { + context.root_handle + } else { + source.common.handle + }; let color = if self.selected.contains(&selection_handle) { [0.15, 0.55, 1.00, 0.35] } else { @@ -1648,9 +1617,15 @@ impl Scene { let render_instance = if context.is_instanced() { let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let key = ( source.common.handle.value(), @@ -1661,9 +1636,9 @@ impl Scene { .collect::>(), color.map(f32::to_bits), ); - let source_id = *wipeout_sources.entry(key).or_insert_with( - crate::scene::model::instance_model::next_source_id, - ); + let source_id = *wipeout_sources + .entry(key) + .or_insert_with(crate::scene::model::instance_model::next_source_id); Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], @@ -1692,8 +1667,7 @@ impl Scene { angle_offset: 0.0, scale: 1.0, world_origin, - draw_depth: context - .draw_depth(source.common.handle, depth_map.as_ref()), + draw_depth: context.draw_depth(source.common.handle, depth_map.as_ref()), }); }, ); @@ -1708,7 +1682,11 @@ impl Scene { ) -> ([f64; 2], Vec<[f32; 2]>) { let origin = [wo.insertion_point.x, wo.insertion_point.y]; let plane = cadkernel::space::Plane::from_axes( - [wo.insertion_point.x, wo.insertion_point.y, wo.insertion_point.z], + [ + wo.insertion_point.x, + wo.insertion_point.y, + wo.insertion_point.z, + ], [wo.u_vector.x, wo.u_vector.y, wo.u_vector.z], [wo.v_vector.x, wo.v_vector.y, wo.v_vector.z], ); @@ -1744,12 +1722,7 @@ impl Scene { let mut clip: Vec<[f64; 2]> = wo .clip_boundary_vertices .iter() - .map(|point| { - [ - point.x + wo.size.x * 0.5, - wo.size.y * 0.5 - point.y, - ] - }) + .map(|point| [point.x + wo.size.x * 0.5, wo.size.y * 0.5 - point.y]) .collect(); if clip.last() != clip.first() { clip.push(clip[0]); @@ -1793,11 +1766,7 @@ impl Scene { world_origin: [f64; 2], boundary: &[[f32; 2]], ) -> Option> { - let plane = cadkernel::space::Plane::from_axes( - plane.origin, - plane.x_axis, - plane.y_axis, - ); + let plane = cadkernel::space::Plane::from_axes(plane.origin, plane.x_axis, plane.y_axis); boundary .iter() .map(|point| { @@ -1815,10 +1784,7 @@ impl Scene { .collect() } - pub(crate) fn hatch_model_from_dxf( - dxf: &DxfHatch, - color: [f32; 4], - ) -> Option { + pub(crate) fn hatch_model_from_dxf(dxf: &DxfHatch, color: [f32; 4]) -> Option { let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z); // Build the boundary in f64 first so the precision-preserving // origin computation below sees full WCS precision. We only cast @@ -1849,9 +1815,7 @@ impl Scene { let mut edge_polys: Vec> = Vec::new(); for edge in &path.edges { if let Some(curve) = crate::entities::hatch::edge_curve(edge) { - edge_polys.push( - curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE), - ); + edge_polys.push(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)); } } let mut local_ring = chain_path_edges(edge_polys); @@ -1902,11 +1866,7 @@ impl Scene { local_boundary.push([f32::NAN, f32::NAN]); } boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.into_iter().map(|[x, y]| [x as f32, y as f32])); boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -1941,18 +1901,19 @@ impl Scene { // lines. The stored offset is the authoritative world-unit spacing, so // the resulting families are already final: no pattern_scale / angle // is re-applied (see `prebaked` below and the HatchModel fields). - let prebaked = !dxf.is_solid - && !dxf.gradient_color.is_enabled() - && !dxf.pattern.lines.is_empty(); + let prebaked = + !dxf.is_solid && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty(); // The gradient's first stop is the fill's start colour (not the // entity colour); capture it so the HatchModel draws stop-0 → stop-1. let mut gradient_color1: Option<[f32; 4]> = None; let mut pattern = if dxf.gradient_color.is_enabled() { let stop = |i: usize| { - dxf.gradient_color.colors.get(i).and_then(|e| e.color.rgb()).map( - |(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0], - ) + dxf.gradient_color + .colors + .get(i) + .and_then(|e| e.color.rgb()) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); @@ -1975,7 +1936,11 @@ impl Scene { model::hatch_model::HatchPattern::Solid } else if prebaked { model::hatch_model::HatchPattern::Pattern( - dxf.pattern.lines.iter().map(family_from_stored_line).collect(), + dxf.pattern + .lines + .iter() + .map(family_from_stored_line) + .collect(), ) } else { let pat_name = &dxf.pattern.name; @@ -2038,10 +2003,18 @@ impl Scene { let mut max_y = f64::NEG_INFINITY; for &[x, y] in &boundary { if x.is_finite() && y.is_finite() { - if x < min_x { min_x = x; } - if y < min_y { min_y = y; } - if x > max_x { max_x = x; } - if y > max_y { max_y = y; } + if x < min_x { + min_x = x; + } + if y < min_y { + min_y = y; + } + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } } } let world_origin = if min_x.is_finite() && min_y.is_finite() { @@ -2129,8 +2102,16 @@ impl Scene { color: gradient_color1.unwrap_or(color), aci: 0, line_weight_px: 1.0, - angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, - scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, + angle_offset: if prebaked { + 0.0 + } else { + dxf.pattern_angle as f32 + }, + scale: if prebaked { + 1.0 + } else { + dxf.pattern_scale as f32 + }, world_origin, draw_depth: 0.0, }) @@ -2168,16 +2149,13 @@ impl Scene { /// at load, so a pattern-scale / background / boundary edit stays /// invisible until the cached model is refreshed (#415). pub fn refresh_fill_model(&mut self, handle: Handle) { - let contextual = self - .document - .get_entity(handle) - .map(|entity| { - crate::scene::annotative::entity_for_annotation_context( - &self.document, - entity, - self.displayed_annotation_scale_handle(), - ) - }); + let contextual = self.document.get_entity(handle).map(|entity| { + crate::scene::annotative::entity_for_annotation_context( + &self.document, + entity, + self.displayed_annotation_scale_handle(), + ) + }); let new_model = match contextual.as_deref() { Some(EntityType::Hatch(dxf)) => { let color = convert::tess_util::aci_to_rgba(&dxf.common.color); @@ -2213,7 +2191,7 @@ impl Scene { e, self.displayed_annotation_scale_handle(), ) - .into_owned(), + .into_owned(), )), EntityType::Solid(s) => Some((s.common.handle, e.clone())), _ => None, @@ -2315,7 +2293,8 @@ impl Scene { // Incremental (post-xref) pass: leave already-tessellated // host solids untouched, only build the newly merged ones. if incremental - && (self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle)) + && (self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle)) { return None; } @@ -2363,7 +2342,11 @@ impl Scene { &self.document, &entity, ); - let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; + let mesh = if top_level { + offset_mesh_lod_set(mesh) + } else { + mesh + }; (handle, mesh, top_level) }) }) @@ -2478,18 +2461,18 @@ impl Scene { } else { // Otherwise reconstruct every ring from render offsets. let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { - let [wx, wy] = model.world_origin; - model - .boundary - .iter() - .map(|&[x, y]| { - if x.is_finite() && y.is_finite() { - [x as f64 + wx, y as f64 + wy] - } else { - [f64::NAN, f64::NAN] - } - }) - .collect() + let [wx, wy] = model.world_origin; + model + .boundary + .iter() + .map(|&[x, y]| { + if x.is_finite() && y.is_finite() { + [x as f64 + wx, y as f64 + wy] + } else { + [f64::NAN, f64::NAN] + } + }) + .collect() } else { Vec::new() }; @@ -2502,31 +2485,31 @@ impl Scene { let mut first = true; let mut ring_index = 0usize; let mut push_ring = |r: &mut Vec, is_outer: bool, index: usize| { - if !r.is_empty() { - let edge = PolylineEdge::new(std::mem::take(r), true); - let handles: Vec<_> = model - .boundary_sources - .as_deref() - .and_then(|sources| sources.get(index)) - .into_iter() - .flatten() - .copied() - .filter(|handle| handle.is_valid()) - .collect(); - let mut bits = 0; - if is_outer { - bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); - bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); + if !r.is_empty() { + let edge = PolylineEdge::new(std::mem::take(r), true); + let handles: Vec<_> = model + .boundary_sources + .as_deref() + .and_then(|sources| sources.get(index)) + .into_iter() + .flatten() + .copied() + .filter(|handle| handle.is_valid()) + .collect(); + let mut bits = 0; + if is_outer { + bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); + bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); + } + let mut path = BoundaryPath::with_flags( + acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits), + ); + path.add_edge(BoundaryEdge::Polyline(edge)); + for handle in handles { + path.add_boundary_handle(handle); + } + dxf.paths.push(path); } - let mut path = BoundaryPath::with_flags( - acadrust::entities::hatch::BoundaryPathFlags::from_bits(bits), - ); - path.add_edge(BoundaryEdge::Polyline(edge)); - for handle in handles { - path.add_boundary_handle(handle); - } - dxf.paths.push(path); - } }; for &[x, y] in wcs { if x.is_finite() && y.is_finite() { @@ -2580,28 +2563,20 @@ impl Scene { let family_angle = (family.angle_deg as f64).to_radians(); let angle = family_angle + rotation; let (family_sin, family_cos) = family_angle.sin_cos(); - let local_offset_x = family.dx as f64 * family_cos - - family.dy as f64 * family_sin; - let local_offset_y = family.dx as f64 * family_sin - + family.dy as f64 * family_cos; + let local_offset_x = family.dx as f64 * family_cos - family.dy as f64 * family_sin; + let local_offset_y = family.dx as f64 * family_sin + family.dy as f64 * family_cos; let base_x = family.x0 as f64 * pattern_scale; let base_y = family.y0 as f64 * pattern_scale; pattern.lines.push(acadrust::entities::HatchPatternLine { angle, base_point: Vector2::new( - pattern_origin[0] - + base_x * rotation_cos - - base_y * rotation_sin, - pattern_origin[1] - + base_x * rotation_sin - + base_y * rotation_cos, + pattern_origin[0] + base_x * rotation_cos - base_y * rotation_sin, + pattern_origin[1] + base_x * rotation_sin + base_y * rotation_cos, ), offset: Vector2::new( - (local_offset_x * rotation_cos - - local_offset_y * rotation_sin) + (local_offset_x * rotation_cos - local_offset_y * rotation_sin) * pattern_scale, - (local_offset_x * rotation_sin - + local_offset_y * rotation_cos) + (local_offset_x * rotation_sin + local_offset_y * rotation_cos) * pattern_scale, ), dash_lengths: family @@ -2642,13 +2617,13 @@ impl Scene { dxf.gradient_color.is_single_color = false; // Linear has no INV name in the standard set: persist an inverted // linear by swapping the colour stops instead. - let (c0, c1) = - if *invert && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) - { - (*color2, model.color) - } else { - (model.color, *color2) - }; + let (c0, c1) = if *invert + && matches!(kind, crate::scene::model::hatch_model::GradientKind::Linear) + { + (*color2, model.color) + } else { + (model.color, *color2) + }; dxf.gradient_color.colors = vec![ acadrust::entities::hatch::GradientColorEntry { value: 0.0, @@ -2703,7 +2678,7 @@ impl Scene { // resetting them, a "new"/"open" (e.g. via automation, which reuses // this same reset) would leak the previous document's constraints // and named parameters into the fresh one. - self.sketch_constraints.clear(); + self.parametric_constraints.clear(); self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_geometry(); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..928cc2844 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -29,17 +29,16 @@ pub enum BlockScalePolicy { mod boundary; mod camera_ops; pub(crate) mod centerline; -pub(crate) mod dimension_assoc; pub(crate) mod centermark; +pub(crate) mod dimension_assoc; mod dwg_native_constraints; mod entity; mod group_layer; mod layout; mod limits; mod modify; -pub mod named_parameters; -mod named_parameters_persist; mod mspace; +pub mod named_parameters; mod page_setup; mod paper; mod preview; @@ -47,22 +46,20 @@ mod project; mod scene_markers; mod selection; pub(crate) use selection::pe_url_of; -pub mod sketch_constraints; -mod sketch_persist; -mod sketch_solve; +pub mod parametric_constraints; +mod parametric_solve; pub(crate) use boundary::{ - boundary_entities, boundary_entities_from_sources, boundary_faces, - boundary_polyline_entities, exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, - hatch_path_ring, ring_source_handles, separated_hatch_path_groups, BoundarySource, + boundary_entities, boundary_entities_from_sources, boundary_faces, boundary_polyline_entities, + exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, hatch_path_ring, + ring_source_handles, separated_hatch_path_groups, BoundarySource, }; // Parallel tessellation free functions live in `convert::tess` (alongside the // other tessellation code); re-exported here so this root and sibling topic // modules (each does `use super::*`) keep referencing them unqualified. pub(crate) use convert::tess::{ - entity_aabb, entity_world_aabb_f64, is_unindexable_entity, - tessellate_entity_dim_text, + entity_aabb, entity_world_aabb_f64, is_unindexable_entity, tessellate_entity_dim_text, }; /// Keep construction-history curves in the same per-entity tessellation memo @@ -82,9 +79,17 @@ pub(crate) fn tessellate_entity( paper_space: bool, ) -> Vec { let mut wires = convert::tess::tessellate_entity( - document, selected, active_viewport, bg_color, anno_scale, - annotation_scale_handle, entity, block_cache, view_aabb, - world_per_pixel, paper_space, + document, + selected, + active_viewport, + bg_color, + anno_scale, + annotation_scale_handle, + entity, + block_cache, + view_aabb, + world_per_pixel, + paper_space, ); if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; @@ -105,11 +110,16 @@ pub(crate) fn tessellate_entity( view::render::render_style_for_viewport(document, &source, active_viewport); for boundary in wires { for curve in boundary { - let points = curve.tessellate(64.0 / std::f64::consts::TAU) - .into_iter().map(|point| plane.point_at(point)); + let points = curve + .tessellate(64.0 / std::f64::consts::TAU) + .into_iter() + .map(|point| plane.point_at(point)); let (points, points_low) = convert::tessellate::points_to_ds(points); let mut wire = WireModel::solid( - handle.value().to_string(), points, color, selected.contains(&handle), + handle.value().to_string(), + points, + color, + selected.contains(&handle), ); wire.points_low = points_low; wire.line_weight_px = line_weight_px; @@ -120,9 +130,17 @@ pub(crate) fn tessellate_entity( boundaries } else { convert::tess::tessellate_entity( - document, selected, active_viewport, bg_color, anno_scale, - annotation_scale_handle, &source, block_cache, view_aabb, - world_per_pixel, paper_space, + document, + selected, + active_viewport, + bg_color, + anno_scale, + annotation_scale_handle, + &source, + block_cache, + view_aabb, + world_per_pixel, + paper_space, ) }; for wire in &mut construction { @@ -334,15 +352,18 @@ pub struct UndoRecording { /// by the command. `None` denotes a newly-created object. object_before: HashMap>, object_order: Vec, - /// First-touch before-images of every sketch-constraint scope a command + /// First-touch before-images of every parametric-constraint scope a command /// changed (e.g. ERASE removing an entity's constraints along with it via - /// `refresh_sketch_constraints`'s deletion policy). `sketch_constraints` + /// `refresh_parametric_constraints`'s deletion policy). `parametric_constraints` /// lives on `Scene`, not in `document`/`document.objects`, so neither /// directory above ever sees this change — it needs its own directory. /// Unlike the two above, there is no "didn't exist before" case: a /// scope's `Vec` entry, once created, is never removed. - sketch_constraints_before: HashMap, - sketch_constraints_order: Vec, + parametric_constraints_before: HashMap< + parametric_constraints::ParametricScope, + parametric_constraints::ParametricConstraintSet, + >, + parametric_constraints_order: Vec, named_parameters_before: Option, poisoned: bool, } @@ -357,21 +378,24 @@ impl UndoRecording { pub fn is_empty(&self) -> bool { self.order.is_empty() && self.object_order.is_empty() - && self.sketch_constraints_order.is_empty() + && self.parametric_constraints_order.is_empty() && self.named_parameters_before.is_none() } /// Consume the recording directories in deterministic first-touch /// order. A `None` image means the entity/object was added by the - /// command; every sketch-constraint scope's before-image is a real - /// `SketchConstraintSet` (its `Vec` entry, once created, is never + /// command; every parametric-constraint scope's before-image is a real + /// `ParametricConstraintSet` (its `Vec` entry, once created, is never /// removed, so there is no "didn't exist before" case there). pub fn into_recorded_images( mut self, ) -> ( Vec<(Handle, Option>)>, Vec<(Handle, Option)>, - Vec<(sketch_constraints::SketchScope, sketch_constraints::SketchConstraintSet)>, + Vec<( + parametric_constraints::ParametricScope, + parametric_constraints::ParametricConstraintSet, + )>, Option, ) { let entities = self @@ -384,12 +408,21 @@ impl UndoRecording { .drain(..) .map(|h| (h, self.object_before.remove(&h).flatten())) .collect(); - let sketch_constraints = self - .sketch_constraints_order + let parametric_constraints = self + .parametric_constraints_order .drain(..) - .filter_map(|scope| self.sketch_constraints_before.remove(&scope).map(|before| (scope, before))) + .filter_map(|scope| { + self.parametric_constraints_before + .remove(&scope) + .map(|before| (scope, before)) + }) .collect(); - (entities, objects, sketch_constraints, self.named_parameters_before) + ( + entities, + objects, + parametric_constraints, + self.named_parameters_before, + ) } /// Entity-only convenience used by the focused Scene delta tests. @@ -897,14 +930,14 @@ fn build_derived_caches_impl( EntityType::RasterImage(img) => { ImageModel::from_raster_image(img).map(|m| (handle, m)) } - EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), - EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { - Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { - ImageModel::from_underlay(u, def).map(|m| (handle, m)) - } + EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), + EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { + Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { + ImageModel::from_underlay(u, def).map(|m| (handle, m)) + } + _ => None, + }, _ => None, - }, - _ => None, }; let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; if done & 0xff == 0 || done == detail_total { @@ -918,8 +951,7 @@ fn build_derived_caches_impl( // Top-level (layout-owned) solids are offset into the render frame; block // definition solids keep block-local coords for per-INSERT instancing. (#123) let facet_res = doc.header.facet_resolution; - let chordal_deflection = - crate::entities::solid3d::display_deflection(&doc.header, facet_res); + let chordal_deflection = crate::entities::solid3d::display_deflection(&doc.header, facet_res); let isolines = doc.header.isolines.max(0) as usize; // Real layout blocks come from the Layout objects' block_record handles — // `BlockRecord::is_layout()` is unreliable here (it flags ordinary blocks). @@ -955,17 +987,13 @@ fn build_derived_caches_impl( isolines, ) .map(|mut mesh| { - material.apply_to_with_face_overrides( - &mut mesh, - doc, - material_base_dir, - ); - crate::scene::model::visual_style_model::apply_mesh_visual_style( - &mut mesh, - doc, - e, - ); - let mesh = if top_level { offset_mesh_lod_set(mesh) } else { mesh }; + material.apply_to_with_face_overrides(&mut mesh, doc, material_base_dir); + crate::scene::model::visual_style_model::apply_mesh_visual_style(&mut mesh, doc, e); + let mesh = if top_level { + offset_mesh_lod_set(mesh) + } else { + mesh + }; (handle, mesh, top_level) }); let done = detail_done.fetch_add(1, Ordering::Relaxed) + 1; @@ -1477,7 +1505,10 @@ fn transform_block_mesh_lod_set( instance_transform: Some(*xform), instance_handle: None, instance_color: set.display_color(), - instance_aabb: bounds.iter().all(|value| value.is_finite()).then_some(bounds), + instance_aabb: bounds + .iter() + .all(|value| value.is_finite()) + .then_some(bounds), } } @@ -1801,10 +1832,8 @@ pub struct Scene { /// filtered variants. Viewports sharing a frozen set share one entry (like /// the resident wire set). Empty for a viewport with no frozen layers (it /// reuses the unfiltered `*_arc` sets directly). - frozen_hatch_cache: - RefCell>)>>, - frozen_wipeout_cache: - RefCell>)>>, + frozen_hatch_cache: RefCell>)>>, + frozen_wipeout_cache: RefCell>)>>, frozen_image_cache: RefCell>)>>, frozen_mesh_cache: RefCell>)>>, /// Viewports that carry layer color/alpha/linetype/lineweight overrides. @@ -1830,13 +1859,9 @@ pub struct Scene { paper_sheet_render_cache: RefCell>, display_plot_style_cache: RefCell>>>, - styled_wire_cache: RefCell< - HashMap<(u64, String), (u64, Arc>)>, - >, - styled_hatch_cache: - RefCell>>>, - styled_wire_fill_cache: - RefCell>>>, + styled_wire_cache: RefCell>)>>, + styled_hatch_cache: RefCell>>>, + styled_wire_fill_cache: RefCell>>>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -1924,16 +1949,15 @@ pub struct Scene { /// Conservative association hint: unknown until scanned, then updated from /// changed entities. Retaining `true` after deletion only costs an extra scan. has_associative_centers: std::cell::Cell>, - /// Persistent parametric constraint sets, one per sketch scope. - /// Materialized into scoped XRecords during save and restored on open. - pub(crate) sketch_constraints: Vec, + /// Runtime parametric constraint sets decoded from standard graph scopes. + pub(crate) parametric_constraints: Vec, /// Session-only visibility overrides for constraint glyphs. - hidden_sketch_constraints: HashSet<( - sketch_constraints::SketchScope, - sketch_constraints::ConstraintId, + hidden_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, )>, - /// Document-wide named-parameter and expression table. - /// Persisted through a single drawing XRecord. + /// Document-wide named-parameter and expression table decoded from + /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours @@ -2070,7 +2094,11 @@ fn section_frame( ); let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?; let base_view = vertical.cross(tangent).try_normalize()?; - let viewing = if data.flags & 4 != 0 { base_view } else { -base_view }; + let viewing = if data.flags & 4 != 0 { + base_view + } else { + -base_view + }; Some((origin, tangent, vertical, viewing)) } @@ -2100,14 +2128,9 @@ fn section_plane( [matrix[2][0], matrix[2][1], matrix[2][2]], ); let inverse = linear.inverse()?; - let translation = acadrust::types::Vector3::new( - matrix[0][3], - matrix[1][3], - matrix[2][3], - ); - let origin = inverse.transform_point( - acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation, - ); + let translation = acadrust::types::Vector3::new(matrix[0][3], matrix[1][3], matrix[2][3]); + let origin = inverse + .transform_point(acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation); let tangent = inverse.transform_point(acadrust::types::Vector3::new( tangent.x, tangent.y, tangent.z, )); @@ -2248,8 +2271,8 @@ impl Scene { layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), - sketch_constraints: Vec::new(), - hidden_sketch_constraints: HashSet::default(), + parametric_constraints: Vec::new(), + hidden_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), @@ -2336,7 +2359,9 @@ impl Scene { } else { self.paper_bg_color }; - let mut key = annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0); + let mut key = annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0); for component in bg { key = key.rotate_left(13) ^ component.to_bits() as u64; } @@ -2432,8 +2457,7 @@ impl Scene { /// rebuild. Every `geometry_epoch` bump must call this exactly once so the /// journal never has a gap (a gap would let a consumer serve stale geometry). fn push_geometry_delta(&self, epoch: u64, changes: Vec<(Handle, ChangeKind)>, full: bool) { - let pending_categories = - std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); + let pending_categories = std::mem::take(&mut *self.pending_removed_categories.borrow_mut()); let removed_categories = if full { HashMap::default() } else { @@ -2681,11 +2705,7 @@ impl Scene { /// Record one document.objects entry before its first mutation. This keeps /// Group erase and RasterImage add on targeted history rather than forcing /// a clone of all document structure. - pub(crate) fn record_undo_object_before( - &mut self, - handle: Handle, - before: Option, - ) { + pub(crate) fn record_undo_object_before(&mut self, handle: Handle, before: Option) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.object_before.contains_key(&handle) { rec.object_order.push(handle); @@ -2694,22 +2714,22 @@ impl Scene { } } - /// Record one sketch-constraint scope's whole-set image before its first + /// Record one parametric-constraint scope's whole-set image before its first /// mutation within the open recording (first touch wins) — e.g. ERASE /// about to remove some of a scope's constraints via - /// `refresh_sketch_constraints`'s deletion policy. `sketch_constraints` + /// `refresh_parametric_constraints`'s deletion policy. `parametric_constraints` /// lives on `Scene`, outside `document`/`document.objects`, so neither /// `record_undo_before` nor `record_undo_object_before` ever sees this /// change on their own. - pub(crate) fn record_undo_sketch_constraints_before( + pub(crate) fn record_undo_parametric_constraints_before( &mut self, - scope: sketch_constraints::SketchScope, - before: sketch_constraints::SketchConstraintSet, + scope: parametric_constraints::ParametricScope, + before: parametric_constraints::ParametricConstraintSet, ) { if let Some(rec) = self.undo_recording.as_mut() { - if !rec.sketch_constraints_before.contains_key(&scope) { - rec.sketch_constraints_order.push(scope); - rec.sketch_constraints_before.insert(scope, before); + if !rec.parametric_constraints_before.contains_key(&scope) { + rec.parametric_constraints_order.push(scope); + rec.parametric_constraints_before.insert(scope, before); } } } @@ -2844,13 +2864,16 @@ impl Scene { changes.push(change); } } - if !self.sketch_constraints.is_empty() { - for change in self.refresh_sketch_constraints(&changes) { + if !self.parametric_constraints.is_empty() { + for change in self.refresh_parametric_constraints(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } } + if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() { + self.sync_native_parametric_graph(); + } if !changes.is_empty() { self.refresh_dependency_index_for_changes(&changes); } @@ -2904,10 +2927,7 @@ impl Scene { /// Removed entities may no longer have a live slot, so fall back to the /// BlockRecord membership list, which deliberately retains their handles /// for symmetric undo/redo restoration. - pub(crate) fn changes_touch_block_definition( - &self, - changes: &[(Handle, ChangeKind)], - ) -> bool { + pub(crate) fn changes_touch_block_definition(&self, changes: &[(Handle, ChangeKind)]) -> bool { let layout_blocks: HashSet = self .document .objects @@ -2998,8 +3018,12 @@ impl Scene { } else if self.current_layout != "Model" { // Active in paper: entered content vp, else the sheet (D6(a)). match self.active_viewport { - Some(h) => { set.insert(0x3000_0000_0000_0000 | h.value()); } - None => { set.insert(0x2000_0000_0000_0000); } + Some(h) => { + set.insert(0x3000_0000_0000_0000 | h.value()); + } + None => { + set.insert(0x2000_0000_0000_0000); + } } } else { // Active in Model: the active tile (always resolvable). @@ -3159,15 +3183,14 @@ impl Scene { ), _ => ([self.document.header.isolines.max(0) as usize; 2], false), }; - let (mut set, wires, center) = - crate::scene::model::solid_model::display_from_solid( - solid, - color, - facet_resolution, - chordal_deflection, - isolines, - planar_isolines, - )?; + let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( + solid, + color, + facet_resolution, + chordal_deflection, + isolines, + planar_isolines, + )?; let name = handle.value().to_string(); for mesh in &mut set.lods { mesh.name = name.clone(); @@ -3211,12 +3234,21 @@ impl Scene { mesh.color = color; } crate::scene::model::material_model::resolve_material_with_base( - &self.document, entity, color, None, self.material_base_dir.as_deref(), - ).apply_to_with_face_overrides( - &mut set, &self.document, self.material_base_dir.as_deref(), + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ) + .apply_to_with_face_overrides( + &mut set, + &self.document, + self.material_base_dir.as_deref(), ); crate::scene::model::visual_style_model::apply_mesh_visual_style( - &mut set, &self.document, entity, + &mut set, + &self.document, + entity, ); } if let Some(entity) = self.document.get_entity_mut(handle) { @@ -3244,18 +3276,16 @@ impl Scene { /// Cache and display a Model-tab kernel solid. A failed preparation leaves /// the previous entity, body and mesh untouched. - pub fn register_solid_model( - &mut self, - handle: Handle, - solid: cadkernel::brep::Body, - ) -> bool { + pub fn register_solid_model(&mut self, handle: Handle, solid: cadkernel::brep::Body) -> bool { let Some(display) = self.prepare_solid_model_display(handle, &solid) else { return false; }; - if !display.0.complete && matches!( - self.document.solid_history_operation(handle), - Some(acadrust::objects::SolidHistoryOperation::Loft(_)) - ) { + if !display.0.complete + && matches!( + self.document.solid_history_operation(handle), + Some(acadrust::objects::SolidHistoryOperation::Loft(_)) + ) + { return false; } self.register_prepared_solid_model(handle, solid, display) @@ -3280,12 +3310,12 @@ impl Scene { let color = self.render_style(e).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( - &self.document, - e, - color, - None, - self.material_base_dir.as_deref(), - ); + &self.document, + e, + color, + None, + self.material_base_dir.as_deref(), + ); if let Some(keep) = &self.refedit_keep { if !keep.contains(&h) { material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( @@ -3326,21 +3356,19 @@ impl Scene { let color = self.render_style(entity).0; let mut material = crate::scene::model::material_model::resolve_material_with_base( - &self.document, - entity, - color, - None, - self.material_base_dir.as_deref(), - ); + &self.document, + entity, + color, + None, + self.material_base_dir.as_deref(), + ); if self .refedit_keep .as_ref() .is_some_and(|keep| !keep.contains(&handle)) { - material.diffuse = crate::scene::cache::block_cache::fade_toward_bg( - material.diffuse, - bg, - ); + material.diffuse = + crate::scene::cache::block_cache::fade_toward_bg(material.diffuse, bg); } (handle, material) }) @@ -3438,35 +3466,27 @@ impl Scene { } if let Some((flags, insertion_base, min_extents, max_extents, min_limits, max_limits)) = self.document.objects.values().find_map(|object| { - let ObjectType::Layout(layout) = object else { - return None; - }; - (layout.name == self.current_layout).then_some(( - layout.flags, - layout.insertion_base, - layout.min_extents, - layout.max_extents, - layout.min_limits, - layout.max_limits, - )) - }) { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.current_layout).then_some(( + layout.flags, + layout.insertion_base, + layout.min_extents, + layout.max_extents, + layout.min_limits, + layout.max_limits, + )) + }) + { self.document.header.paper_space_linetype_scaling = flags & 1 != 0; self.document.header.paper_space_limit_check = flags & 2 != 0; - self.document.header.paper_space_insertion_base = acadrust::types::Vector3::new( - insertion_base.0, - insertion_base.1, - insertion_base.2, - ); - self.document.header.paper_space_extents_min = acadrust::types::Vector3::new( - min_extents.0, - min_extents.1, - min_extents.2, - ); - self.document.header.paper_space_extents_max = acadrust::types::Vector3::new( - max_extents.0, - max_extents.1, - max_extents.2, - ); + self.document.header.paper_space_insertion_base = + acadrust::types::Vector3::new(insertion_base.0, insertion_base.1, insertion_base.2); + self.document.header.paper_space_extents_min = + acadrust::types::Vector3::new(min_extents.0, min_extents.1, min_extents.2); + self.document.header.paper_space_extents_max = + acadrust::types::Vector3::new(max_extents.0, max_extents.1, max_extents.2); self.document.header.paper_space_limits_min = acadrust::types::Vector2::new(min_limits.0, min_limits.1); self.document.header.paper_space_limits_max = @@ -4006,8 +4026,7 @@ impl Scene { ps.paper_image_origin_x = l.paper_image_origin_x; ps.paper_image_origin_y = l.paper_image_origin_y; ps.shade_plot_mode = ShadePlotMode::from_code(l.shade_plot_mode); - ps.shade_plot_resolution = - ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); + ps.shade_plot_resolution = ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); ps.shade_plot_dpi = l.shade_plot_dpi; ps.plot_view_handle = l.plot_view_handle; ps.visual_style_handle = l.visual_style_handle; @@ -4216,9 +4235,7 @@ impl Scene { (x0 + left * factor, y0 + bottom * factor), (x1 - right * factor, y1 - top * factor), ); - if printable.1.0 - printable.0.0 < 1e-6 - || printable.1.1 - printable.0.1 < 1e-6 - { + if printable.1 .0 - printable.0 .0 < 1e-6 || printable.1 .1 - printable.0 .1 < 1e-6 { Some(((x0, y0), (x1, y1))) } else { Some(printable) @@ -4458,14 +4475,10 @@ impl Scene { if !has_matching_family { let unit_factor = self.annotation_scale_unit_factor(); - visible.extend( - self.default_scales() - .iter() - .map(|&(label, base_vp)| { - let vp = base_vp * unit_factor; - (label.to_string(), (1.0 / vp) as f32, vp) - }), - ); + visible.extend(self.default_scales().iter().map(|&(label, base_vp)| { + let vp = base_vp * unit_factor; + (label.to_string(), (1.0 / vp) as f32, vp) + })); } // If the current scale belongs to the opposite family and has no @@ -4542,14 +4555,17 @@ impl Scene { pub(crate) fn paper_annotation_scale_handle(&self) -> Option { self.scale_object_handle("1:1").or_else(|| { - self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Scale(scale) - if !scale.is_temporary && (scale.factor() - 1.0).abs() <= 1.0e-9 => - { - Some(*handle) - } - _ => None, - }) + self.document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Scale(scale) + if !scale.is_temporary && (scale.factor() - 1.0).abs() <= 1.0e-9 => + { + Some(*handle) + } + _ => None, + }) }) } @@ -4578,11 +4594,8 @@ impl Scene { let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else { return 1.0; }; - let paper_to_model = vp_effective_scale( - viewport.custom_scale, - viewport.view_height, - viewport.height, - ); + let paper_to_model = + vp_effective_scale(viewport.custom_scale, viewport.view_height, viewport.height); if paper_to_model.is_finite() && paper_to_model > 1.0e-9 { 1.0 / paper_to_model } else { @@ -4615,14 +4628,17 @@ impl Scene { } fn current_layout_object_handle(&self) -> Option { - self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Layout(layout) - if layout.name.eq_ignore_ascii_case(&self.current_layout) => - { - Some(*handle) - } - _ => None, - }) + self.document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Layout(layout) + if layout.name.eq_ignore_ascii_case(&self.current_layout) => + { + Some(*handle) + } + _ => None, + }) } pub fn annotation_all_visible(&self) -> bool { @@ -4718,12 +4734,10 @@ impl Scene { .collect(); let mut added = 0; for handle in handles { - let existed = crate::scene::annotative::object_scale_memberships( - &self.document, - handle, - ) - .iter() - .any(|(_, member)| *member == scale); + let existed = + crate::scene::annotative::object_scale_memberships(&self.document, handle) + .iter() + .any(|(_, member)| *member == scale); if !existed && crate::scene::annotative::create_annotation_context( &mut self.document, @@ -4859,16 +4873,23 @@ impl Scene { sh, ); } - let replacement = self.document.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Scale(scale) if *handle != sh && !scale.is_temporary => Some(*handle), - _ => None, - }); + let replacement = self + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Scale(scale) if *handle != sh && !scale.is_temporary => Some(*handle), + _ => None, + }); let viewports: Vec<_> = self .document .entities() .filter_map(|entity| match entity { EntityType::Viewport(viewport) - if self.document.viewport_annotation_scale(viewport.common.handle) == Some(sh) => + if self + .document + .viewport_annotation_scale(viewport.common.handle) + == Some(sh) => { Some(viewport.common.handle) } @@ -5171,11 +5192,17 @@ impl Scene { } self.active_viewport .filter(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) }) .or_else(|| self.first_viewport_handle()) @@ -5187,18 +5214,27 @@ impl Scene { } self.active_viewport .filter(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) .or_else(|| { self.selected.iter().copied().find(|handle| { - matches!(self.document.get_entity(*handle), Some(EntityType::Viewport(_))) + matches!( + self.document.get_entity(*handle), + Some(EntityType::Viewport(_)) + ) }) }) } fn viewport_scale_handle(&self, viewport: Handle) -> Option { if let Some(handle) = self.document.viewport_annotation_scale(viewport) { - if matches!(self.document.objects.get(&handle), Some(ObjectType::Scale(_))) { + if matches!( + self.document.objects.get(&handle), + Some(ObjectType::Scale(_)) + ) { return Some(handle); } } @@ -5243,11 +5279,12 @@ impl Scene { _ => None, }) .or_else(|| { - self.paper_annotation_scale_handle() - .and_then(|handle| match self.document.objects.get(&handle) { + self.paper_annotation_scale_handle().and_then(|handle| { + match self.document.objects.get(&handle) { Some(ObjectType::Scale(scale)) => Some(scale.name.clone()), _ => None, - }) + } + }) }) .unwrap_or_else(|| "1:1".to_string()) } @@ -5257,11 +5294,7 @@ impl Scene { self.set_viewport_scale_named_for(viewport, name) } - pub fn set_viewport_scale_named_for( - &mut self, - viewport: Handle, - name: &str, - ) -> Option { + pub fn set_viewport_scale_named_for(&mut self, viewport: Handle, name: &str) -> Option { let scale_handle = self.scale_handle_ensuring(name)?; let unit_factor = self.annotation_scale_unit_factor(); @@ -5306,18 +5339,11 @@ impl Scene { return None; }; - let effective = vp_effective_scale( - vp.custom_scale, - vp.view_height, - vp.height, - ); + let effective = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); let expected = scale.factor() * self.annotation_scale_unit_factor(); - Some( - (effective - expected).abs() - <= 1.0e-6 * expected.max(1.0), - ) + Some((effective - expected).abs() <= 1.0e-6 * expected.max(1.0)) } pub fn sync_viewport_annotation_scale(&mut self) -> bool { let Some(viewport) = self.explicit_viewport_handle() else { @@ -5594,7 +5620,9 @@ impl Scene { mix(anno_scale_override .map(|scale| scale.to_bits() as u64) .unwrap_or(u64::MAX)); - mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); + mix(annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); // quantized_wpp removed: GPU analytical rendering for circles/arcs/ @@ -5631,10 +5659,7 @@ impl Scene { /// incremental resident patch, else `None`. Read (not consumed) so every /// Model tile applies the same patch; gen-matching keeps it from applying to /// a viewport that rebuilt independently. - pub(crate) fn model_wire_patch_for( - &self, - gen: u64, - ) -> Option<(u64, Arc)> { + pub(crate) fn model_wire_patch_for(&self, gen: u64) -> Option<(u64, Arc)> { match &*self.model_wire_gpu_patch.borrow() { Some((prev, new, patch)) if *new == gen => Some((*prev, Arc::clone(patch))), _ => None, @@ -5830,15 +5855,12 @@ impl Scene { // face change made deleting an ordinary line rescan/re-upload the whole // fill pass on large drawings. let old_face_pass_changed = deltas.iter().any(|(handle, _)| { - layout - .ranges - .get(handle) - .is_some_and(|&(start, len)| { - start + len <= layout.marker_start - && owned[start..start + len] - .iter() - .any(|wire| !wire.fill_tris.is_empty()) - }) + layout.ranges.get(handle).is_some_and(|&(start, len)| { + start + len <= layout.marker_start + && owned[start..start + len] + .iter() + .any(|wire| !wire.fill_tris.is_empty()) + }) }); // Keep the submission-order directory current. Adds are inserted by @@ -5847,9 +5869,7 @@ impl Scene { // commit restores them at their original position. { let cache = self.sort_cache.borrow(); - let sort_map = cache - .as_ref() - .and_then(|(_, index)| index.get(&block)); + let sort_map = cache.as_ref().and_then(|(_, index)| index.get(&block)); let effective = |handle: Handle| { sort_map .and_then(|map| map.get(&handle.value())) @@ -5894,12 +5914,7 @@ impl Scene { return None; } for slot in &mut owned[start..start + old_len] { - *slot = WireModel::solid( - String::new(), - Vec::new(), - [0.0; 4], - false, - ); + *slot = WireModel::solid(String::new(), Vec::new(), [0.0; 4], false); slot.aabb = [ f32::INFINITY, f32::INFINITY, @@ -5908,8 +5923,7 @@ impl Scene { ]; } layout.vacant.insert(handle, (start, old_len)); - layout.tombstoned_wires = - layout.tombstoned_wires.saturating_add(old_len); + layout.tombstoned_wires = layout.tombstoned_wires.saturating_add(old_len); index_edits.push(WireIndexEdit { handle, start, @@ -5925,15 +5939,11 @@ impl Scene { if vacant_len != new_len || start + vacant_len > layout.marker_start { return None; } - for (slot, wire) in owned[start..start + vacant_len] - .iter_mut() - .zip(new_run) - { + for (slot, wire) in owned[start..start + vacant_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); - layout.tombstoned_wires = - layout.tombstoned_wires.saturating_sub(vacant_len); + layout.tombstoned_wires = layout.tombstoned_wires.saturating_sub(vacant_len); index_edits.push(WireIndexEdit { handle, start, @@ -5949,10 +5959,7 @@ impl Scene { return None; } if old_len == new_len { - for (slot, wire) in owned[start..start + old_len] - .iter_mut() - .zip(new_run) - { + for (slot, wire) in owned[start..start + old_len].iter_mut().zip(new_run) { *slot = wire; } layout.ranges.insert(handle, (start, new_len)); @@ -6009,9 +6016,7 @@ impl Scene { } let added: HashSet = deltas .iter() - .filter_map(|&(handle, kind)| { - matches!(kind, ChangeKind::Added).then_some(handle) - }) + .filter_map(|&(handle, kind)| matches!(kind, ChangeKind::Added).then_some(handle)) .collect(); let mut saw_added = false; let mut new_handles_are_suffix = true; @@ -6025,10 +6030,12 @@ impl Scene { } let face_pass_changed = old_face_pass_changed || deltas.iter().any(|&(handle, _)| { - matches!(self.document.get_entity(handle), Some(EntityType::Face3D(_))) - || gpu_runs.get(&handle).is_some_and(|run| { - run.iter().any(|wire| !wire.fill_tris.is_empty()) - }) + matches!( + self.document.get_entity(handle), + Some(EntityType::Face3D(_)) + ) || gpu_runs + .get(&handle) + .is_some_and(|run| run.iter().any(|wire| !wire.fill_tris.is_empty())) }); let arc = Arc::new(owned); @@ -6301,20 +6308,15 @@ impl Scene { incremental_ok = false; break; } - let restored = cache - .blocks - .get(&block) - .and_then(|order| { - order.iter().find(|entry| entry.handle == value) - }); + let restored = cache.blocks.get(&block).and_then(|order| { + order.iter().find(|entry| entry.handle == value) + }); let Some(entry) = restored else { incremental_ok = false; break; }; - depths.insert( - value, - draw_depth_value(entry.label, entry.half_label), - ); + depths + .insert(value, draw_depth_value(entry.label, entry.half_label)); continue; } // A newly allocated handle cannot already have a @@ -6461,7 +6463,11 @@ impl Scene { // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; + let sel_sig = if tint_selected { + self.selected_hatch_sig() + } else { + 0 + }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { @@ -6510,10 +6516,9 @@ impl Scene { None, tint_selected, )); - self.hatch_cache.borrow_mut().insert( - key, - (self.geometry_epoch, sel_sig, Arc::clone(&arc)), - ); + self.hatch_cache + .borrow_mut() + .insert(key, (self.geometry_epoch, sel_sig, Arc::clone(&arc))); arc } @@ -6581,12 +6586,8 @@ impl Scene { } else { self.paper_annotation_scale_handle() }; - let arc = Arc::new(self.wipeout_models( - target_block, - None, - scale, - self.annotation_all_visible(), - )); + let arc = + Arc::new(self.wipeout_models(target_block, None, scale, self.annotation_all_visible())); self.wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); @@ -6673,8 +6674,7 @@ impl Scene { graph.walk_root( root, |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { let handle = entity.common().handle; @@ -6683,9 +6683,7 @@ impl Scene { }; let mut placed = model.clone(); if context.is_instanced() { - for (corner, low) in - placed.corners.iter_mut().zip(&mut placed.corners_low) - { + for (corner, low) in placed.corners.iter_mut().zip(&mut placed.corners_low) { let point = acadrust::types::Vector3::new( corner[0] as f64 + low[0] as f64, corner[1] as f64 + low[1] as f64, @@ -6706,11 +6704,7 @@ impl Scene { vertex.pos[2] as f64 + vertex.pos_low[2] as f64, ); let point = context.transform.apply(point); - vertex.pos = [ - point.x as f32, - point.y as f32, - point.z as f32, - ]; + vertex.pos = [point.x as f32, point.y as f32, point.z as f32]; vertex.pos_low = [ (point.x - vertex.pos[0] as f64) as f32, (point.y - vertex.pos[1] as f64) as f32, @@ -6719,19 +6713,24 @@ impl Scene { } let matrix = &context.transform.matrix.m; let linear = [ - matrix[0][0].to_bits(), matrix[0][1].to_bits(), matrix[0][2].to_bits(), - matrix[1][0].to_bits(), matrix[1][1].to_bits(), matrix[1][2].to_bits(), - matrix[2][0].to_bits(), matrix[2][1].to_bits(), matrix[2][2].to_bits(), + matrix[0][0].to_bits(), + matrix[0][1].to_bits(), + matrix[0][2].to_bits(), + matrix[1][0].to_bits(), + matrix[1][1].to_bits(), + matrix[1][2].to_bits(), + matrix[2][0].to_bits(), + matrix[2][1].to_bits(), + matrix[2][2].to_bits(), ]; let source_id = *image_sources .entry((handle.value(), linear)) .or_insert_with(crate::scene::model::instance_model::next_source_id); - placed.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { + placed.render_instance = + Some(crate::scene::model::instance_model::RenderInstance { source_id, translation: [matrix[0][3], matrix[1][3], matrix[2][3]], - }, - ); + }); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); models.push(placed); @@ -6944,10 +6943,7 @@ impl Scene { let Some(handle) = set.entity_handle() else { continue; }; - lookup - .entry(handle) - .or_default() - .push(index as u32); + lookup.entry(handle).or_default().push(index as u32); } let lookup = Arc::new(lookup); *self.mesh_pick_lookup_cache.borrow_mut() = @@ -7042,17 +7038,15 @@ impl Scene { all } - fn active_live_section( - &self, - target_block: Handle, - ) -> Option { + fn active_live_section(&self, target_block: Handle) -> Option { self.document .entities() .filter_map(|entity| { let EntityType::Extended(extended) = entity else { return None; }; - let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data else { + let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data + else { return None; }; let owner = extended.common.owner_handle; @@ -7061,11 +7055,12 @@ impl Scene { && (owner.is_null() || owner == target_block)) .then(|| LiveSection { data: data.clone(), - slice_depth: crate::entities::extended::section_is_slice(extended) - .then(|| { + slice_depth: crate::entities::extended::section_is_slice(extended).then( + || { crate::entities::extended::section_slice_depth(extended) .unwrap_or(0.0) - }), + }, + ), }) }) .last() @@ -7091,9 +7086,7 @@ impl Scene { let Some(body) = self.sectioned_body(handle, section)? else { return Ok(None); }; - let (_, wires, center) = self - .prepare_solid_model_display(handle, &body) - .ok_or(())?; + let (_, wires, center) = self.prepare_solid_model_display(handle, &body).ok_or(())?; let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); match &mut entity { @@ -7179,13 +7172,8 @@ impl Scene { if depth <= 1e-12 { return Ok(Some(current)); } - let back = section_plane( - origin + viewing * depth, - tangent, - -viewing, - body_to_world, - ) - .ok_or(())?; + let back = + section_plane(origin + viewing * depth, tangent, -viewing, body_to_world).ok_or(())?; let Some(next) = keep_section_positive(¤t, back)? else { return Ok(None); }; @@ -7208,8 +7196,16 @@ impl Scene { return Ok(Some(current)); } - let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; - let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; + let first = data + .vertices + .first() + .map(|point| glam::DVec3::new(point.x, point.y, point.z)) + .ok_or(())?; + let last = data + .vertices + .last() + .map(|point| glam::DVec3::new(point.x, point.y, point.z)) + .ok_or(())?; for plane in [ section_plane(first, vertical, tangent, body_to_world), section_plane(last, vertical, -tangent, body_to_world), @@ -7348,7 +7344,11 @@ impl Scene { ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); - let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; + let sel = if tint_selected { + self.selected_hatch_sig() + } else { + 0 + }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -7380,8 +7380,8 @@ impl Scene { let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); let all_visible = self.annotation_all_visible(); - let context_sig = scale.map_or(0, |handle| handle.value()) - ^ u64::from(all_visible).rotate_left(61); + let context_sig = + scale.map_or(0, |handle| handle.value()) ^ u64::from(all_visible).rotate_left(61); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); if let Some((e, arc)) = self.frozen_wipeout_cache.borrow().get(&key) { @@ -7389,12 +7389,7 @@ impl Scene { return Arc::clone(arc); } } - let arc = Arc::new(self.wipeout_models( - target_block, - Some(frozen), - scale, - all_visible, - )); + let arc = Arc::new(self.wipeout_models(target_block, Some(frozen), scale, all_visible)); self.frozen_wipeout_cache .borrow_mut() .insert(key, (self.geometry_epoch, Arc::clone(&arc))); @@ -7550,8 +7545,7 @@ impl Scene { graph.walk_root( self.render_scene_root(layout_block), |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) }, |entity, context| { if !context.is_instanced() { @@ -7563,8 +7557,7 @@ impl Scene { }; let sectioned; let set = if let Some((section, body)) = live_section.and_then(|section| { - self.section_source_body(handle) - .map(|body| (section, body)) + self.section_source_body(handle).map(|body| (section, body)) }) { match Self::section_body( &body, @@ -7591,14 +7584,10 @@ impl Scene { }; let inherit = self.mesh_inherit_for_path(&context.insert_path); let own_alpha = set.display_color().map_or(1.0, |color| color[3]); - let mut transformed = - transform_block_mesh_lod_set(set, &context.transform); - if let Some(color) = self.block_mesh_override_color( - entity, - handle, - inherit.as_ref(), - own_alpha, - ) { + let mut transformed = transform_block_mesh_lod_set(set, &context.transform); + if let Some(color) = + self.block_mesh_override_color(entity, handle, inherit.as_ref(), own_alpha) + { transformed.instance_color = Some(color); if let Some(material) = transformed.material.as_mut() { if material.handle.is_none() { @@ -7606,8 +7595,7 @@ impl Scene { } } } - if let Some(material) = - self.block_mesh_override_material(entity, inherit.as_ref()) + if let Some(material) = self.block_mesh_override_material(entity, inherit.as_ref()) { material.apply_to_with_face_overrides( &mut transformed, @@ -7622,10 +7610,7 @@ impl Scene { out } - fn mesh_inherit_for_path( - &self, - path: &[DxfInsert], - ) -> Option { + fn mesh_inherit_for_path(&self, path: &[DxfInsert]) -> Option { let first = path.first()?; let entity = EntityType::Insert(first.clone()); let bg = self.current_bg(); @@ -7634,31 +7619,26 @@ impl Scene { bg, ); let layer0_color = crate::scene::view::render::adapt_to_bg( - crate::scene::view::render::layer_render_style( - &self.document, - &first.common.layer, - ) - .color, + crate::scene::view::render::layer_render_style(&self.document, &first.common.layer) + .color, bg, ); let mut inherit = BlockMeshInherit { insert_color, layer0_color, - insert_material: - crate::scene::model::material_model::resolve_material_with_base( - &self.document, - &entity, - insert_color, - None, - self.material_base_dir.as_deref(), - ), - layer0_material: - crate::scene::model::material_model::resolve_layer_material_with_base( - &self.document, - &first.common.layer, - layer0_color, - self.material_base_dir.as_deref(), - ), + insert_material: crate::scene::model::material_model::resolve_material_with_base( + &self.document, + &entity, + insert_color, + None, + self.material_base_dir.as_deref(), + ), + layer0_material: crate::scene::model::material_model::resolve_layer_material_with_base( + &self.document, + &first.common.layer, + layer0_color, + self.material_base_dir.as_deref(), + ), }; for insert in &path[1..] { inherit = self.chain_mesh_inherit(insert, &inherit); @@ -7689,10 +7669,7 @@ impl Scene { let on_l0 = crate::scene::view::render::is_effective_layer_zero(&ins.common.layer); let insert_entity = EntityType::Insert(ins.clone()); let has_book_color = - crate::scene::view::render::has_resolved_book_color( - &self.document, - &insert_entity, - ); + crate::scene::view::render::has_resolved_book_color(&self.document, &insert_entity); let child_ins_color = if !has_book_color && ins.common.color == Color::ByBlock { parent.insert_color } else if !has_book_color && on_l0 && ins.common.color == Color::ByLayer { @@ -7762,8 +7739,7 @@ impl Scene { use acadrust::types::Color; let common = e.common(); let on_l0 = crate::scene::view::render::is_effective_layer_zero(&common.layer); - let has_book_color = - crate::scene::view::render::has_resolved_book_color(&self.document, e); + let has_book_color = crate::scene::view::render::has_resolved_book_color(&self.document, e); let mut c = if !has_book_color && common.color == Color::ByBlock { inherit.insert_color } else if !has_book_color && on_l0 && common.color == Color::ByLayer { @@ -7870,21 +7846,17 @@ impl Scene { let c = self.insert_hatch_cache.borrow(); if let Some((epoch, cached_space, ref arc)) = *c { if cached_space == space_key - && self.category_cache_valid( - epoch, - CACHE_CATEGORY_INSERT_HATCH, - |handle| { - self.document.get_entity(handle).is_some_and(|entity| { - render_graph::entity_render_block_uses( - &self.document, - entity, - self.annotation_scale, - ) - .into_iter() - .any(|block_use| block_use.active) - }) - }, - ) + && self.category_cache_valid(epoch, CACHE_CATEGORY_INSERT_HATCH, |handle| { + self.document.get_entity(handle).is_some_and(|entity| { + render_graph::entity_render_block_uses( + &self.document, + entity, + self.annotation_scale, + ) + .into_iter() + .any(|block_use| block_use.active) + }) + }) { let arc = Arc::clone(arc); drop(c); @@ -7941,20 +7913,14 @@ impl Scene { { continue; } - if !self.belongs_to_visible_block( - common.handle, - common.owner_handle, - interaction_block, - ) { + if !self.belongs_to_visible_block(common.handle, common.owner_handle, interaction_block) + { continue; } - for block_use in render_graph::entity_render_block_uses( - &self.document, - host, - self.annotation_scale, - ) - .into_iter() - .filter(|block_use| block_use.active) + for block_use in + render_graph::entity_render_block_uses(&self.document, host, self.annotation_scale) + .into_iter() + .filter(|block_use| block_use.active) { if !render_graph::block_contains_hatch( &self.document, @@ -7980,8 +7946,7 @@ impl Scene { context.style_for(&self.document, entity).0, self.current_bg(), ); - let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) - else { + let Some(mut model) = Self::hatch_model_from_dxf(&hatch, color) else { return; }; for clip in &context.clips { @@ -8066,9 +8031,8 @@ impl Scene { ) -> Option> { let source = Arc::as_ptr(wires) as usize; let mut cache = self.interaction_index_cache.borrow_mut(); - cache.retain(|(epoch, _, weak, _)| { - *epoch == self.geometry_epoch && weak.strong_count() > 0 - }); + cache + .retain(|(epoch, _, weak, _)| *epoch == self.geometry_epoch && weak.strong_count() > 0); if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| { *ptr == source && weak @@ -8104,21 +8068,13 @@ impl Scene { && weak.strong_count() > 0 && !(*cached_epoch == epoch && *ptr == source) }); - cache.push(( - epoch, - source, - Arc::downgrade(&wires), - Arc::clone(&index), - )); + cache.push((epoch, source, Arc::downgrade(&wires), Arc::clone(&index))); if cache.len() > MAX_CACHED_SOURCES { cache.remove(0); } if epoch == self.geometry_epoch && self.current_layout == "Model" { - *self.interaction_base_index_cache.borrow_mut() = Some(( - epoch, - self.interaction_space_key(), - index, - )); + *self.interaction_base_index_cache.borrow_mut() = + Some((epoch, self.interaction_space_key(), index)); } } @@ -8191,8 +8147,7 @@ impl Scene { (*epoch, Arc::clone(index)) }; let changes = self.replay_since(epoch)?; - (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES) - .then_some((epoch, index, changes)) + (changes.len() <= Self::INTERACTION_OVERLAY_MAX_HANDLES).then_some((epoch, index, changes)) } fn interaction_overlay_changed_index( @@ -8225,9 +8180,8 @@ impl Scene { }) .collect(); let wires = Arc::new(self.wire_models_for(&changed_live)); - let index = Arc::new( - crate::scene::pick::interaction_index::InteractionIndex::build(&wires), - ); + let index = + Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires)); *self.interaction_overlay_index_cache.borrow_mut() = Some(( self.geometry_epoch, base_epoch, @@ -8312,11 +8266,8 @@ impl Scene { self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); - let (local, base_slots, changed_slots) = self.interaction_overlay_wires( - keys.iter().copied(), - &changes, - &changed_wires, - ); + let (local, base_slots, changed_slots) = + self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = if area_only { @@ -8325,13 +8276,13 @@ impl Scene { base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb) }; if area_only { - result.extend_indexed_area( - changed_index.query_remapped_xy_area(local, &changed_slots, aabb), - ); + result.extend_indexed_area(changed_index.query_remapped_xy_area( + local, + &changed_slots, + aabb, + )); } else { - result.extend_indexed( - changed_index.query_remapped_xy(local, &changed_slots, aabb), - ); + result.extend_indexed(changed_index.query_remapped_xy(local, &changed_slots, aabb)); } let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; @@ -8357,7 +8308,7 @@ impl Scene { } } else if allow_pending_empty && self.interaction_index_pending_key.get() - == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { @@ -8384,11 +8335,8 @@ impl Scene { self.interaction_overlay_changed_index(base_epoch, &changes); let changed_ms = t_changed.elapsed().as_secs_f64() * 1000.0; let t_local = iced::time::Instant::now(); - let (local, base_slots, changed_slots) = self.interaction_overlay_wires( - keys.iter().copied(), - &changes, - &changed_wires, - ); + let (local, base_slots, changed_slots) = + self.interaction_overlay_wires(keys.iter().copied(), &changes, &changed_wires); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); let mut result = base.query_remapped_screen( @@ -8427,7 +8375,7 @@ impl Scene { index.query_screen(wires, screen_rect, view_rot, eye, bounds) } else if allow_pending_empty && self.interaction_index_pending_key.get() - == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) { crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) } else { @@ -8443,25 +8391,16 @@ impl Scene { include_line_weight: bool, ) -> f32 { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { - let (_, changed) = - self.interaction_overlay_changed_index(base_epoch, &changes); + let (_, changed) = self.interaction_overlay_changed_index(base_epoch, &changes); base.pick_radius_px( - changed.pick_radius_px( - base_radius_px, - viewport_height_px, - include_line_weight, - ), + changed.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight), viewport_height_px, include_line_weight, ) } else { self.cached_interaction_index(wires) .map_or(base_radius_px, |index| { - index.pick_radius_px( - base_radius_px, - viewport_height_px, - include_line_weight, - ) + index.pick_radius_px(base_radius_px, viewport_height_px, include_line_weight) }) } } @@ -8503,13 +8442,13 @@ impl Scene { *epoch, CACHE_CATEGORY_INTERACTION, |handle| { - self.hatches.contains_key(&handle) - || self.meshes.contains_key(&handle) - || self.block_meshes.contains_key(&handle) - || matches!( - self.document.get_entity(handle), - Some(EntityType::Insert(_)) - ) + self.hatches.contains_key(&handle) + || self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle) + || matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) }, ) => { @@ -8706,8 +8645,7 @@ impl Scene { { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - let pad_px = - self.indexed_interaction_pick_radius(&wires, 0.0, bounds.height, true); + let pad_px = self.indexed_interaction_pick_radius(&wires, 0.0, bounds.height, true); if flat_ortho { let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) @@ -8923,8 +8861,14 @@ impl Scene { rect }, ); - let area_candidates = - self.interaction_candidates_in_aabb(all_wires, world_aabb, screen_rect, view_rot, eye, bounds); + let area_candidates = self.interaction_candidates_in_aabb( + all_wires, + world_aabb, + screen_rect, + view_rot, + eye, + bounds, + ); let candidate_handles = self.interaction_candidate_handles(&area_candidates); let wire_hits = if open_fence { crate::scene::pick::hit_test::poly_fence_hit( @@ -9058,14 +9002,7 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { - self.solid_hit( - cursor, - view_rot, - eye, - bounds, - candidate_handles, - false, - ) + self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, false) } /// Normal object selection for shaded 3D bodies is edge based. This keeps @@ -9089,12 +9026,7 @@ impl Scene { return None; } let (edges, edges_low) = set.geometry_edges(); - (!edges.is_empty()).then_some(( - handle, - edges, - edges_low, - set.instance_transform, - )) + (!edges.is_empty()).then_some((handle, edges, edges_low, set.instance_transform)) }), view_rot, eye, @@ -9160,10 +9092,7 @@ impl Scene { model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) } - pub fn view_center_surface_pivot( - &self, - bounds: iced::Rectangle, - ) -> Option { + pub fn view_center_surface_pivot(&self, bounds: iced::Rectangle) -> Option { let camera = self.camera.borrow(); let view_rot = camera.view_proj_rte(bounds); let eye = camera.eye(); @@ -9199,14 +9128,7 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { - self.solid_hit( - cursor, - view_rot, - eye, - bounds, - candidate_handles, - true, - ) + self.solid_hit(cursor, view_rot, eye, bounds, candidate_handles, true) } fn solid_hit( @@ -9606,7 +9528,10 @@ impl Scene { let members = self.block_members(); visible_index_ms = crate::perf::elapsed_ms(t_members); let (_, by_block) = &*members; - let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); + let claimed = by_block + .get(&block_handle) + .map(Vec::as_slice) + .unwrap_or(&[]); visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); for &handle in claimed { @@ -9727,7 +9652,9 @@ impl Scene { } } mix(anno.to_bits() as u64); - mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); + mix(annotation_scale_handle + .map(|handle| handle.value()) + .unwrap_or(0)); mix(all_visible as u64); // Direct entities and inherited/faded block colours still bake // the background before Batches::finalize records its inputs. @@ -9950,7 +9877,10 @@ vis_index={:.1} visible_probe={:.1}", fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { { let cache = self.unindexable_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + if cache + .as_ref() + .is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) + { drop(cache); return std::cell::Ref::map(self.unindexable_cache.borrow(), |c| { c.as_ref().unwrap() @@ -9985,9 +9915,9 @@ vis_index={:.1} visible_probe={:.1}", } if let Some(since) = cached_epoch { - let lone_add = self.replay_since(since).filter(|deltas| { - deltas.len() == 1 && deltas[0].1 == ChangeKind::Added - }); + let lone_add = self + .replay_since(since) + .filter(|deltas| deltas.len() == 1 && deltas[0].1 == ChangeKind::Added); if let Some(deltas) = lone_add { let handle = deltas[0].0; // Resolve the owner before touching the cache: both lookups @@ -10026,8 +9956,7 @@ vis_index={:.1} visible_probe={:.1}", } } } - *self.block_members_cache.borrow_mut() = - Some((self.geometry_epoch, members)); + *self.block_members_cache.borrow_mut() = Some((self.geometry_epoch, members)); std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) } @@ -10159,9 +10088,7 @@ vis_index={:.1} visible_probe={:.1}", EntityType::Dimension(dimension) => { normalize_name(&dimension.base().style_name).hash(&mut hasher) } - EntityType::Leader(leader) => { - normalize_name(&leader.dimension_style).hash(&mut hasher) - } + EntityType::Leader(leader) => normalize_name(&leader.dimension_style).hash(&mut hasher), EntityType::Tolerance(tolerance) => { normalize_name(&tolerance.dimension_style_name).hash(&mut hasher) } @@ -10206,8 +10133,7 @@ vis_index={:.1} visible_probe={:.1}", let Some(entity) = self.document.get_entity(*handle) else { return false; }; - index.signatures.get(handle).copied() - == Some(self.entity_dependency_signature(entity)) + index.signatures.get(handle).copied() == Some(self.entity_dependency_signature(entity)) }); if !unchanged { cache.take(); @@ -10379,10 +10305,7 @@ vis_index={:.1} visible_probe={:.1}", } EntityType::Dimension(dimension) => { add(&mut index.dim_styles, &dimension.base().style_name); - if let Some(style) = self - .document - .dim_styles - .get(&dimension.base().style_name) + if let Some(style) = self.document.dim_styles.get(&dimension.base().style_name) { add(&mut index.text_styles, &style.dimtxsty); } @@ -10449,7 +10372,8 @@ vis_index={:.1} visible_probe={:.1}", EntityType::MultiLeader(leader) => { add_handle(&mut index.object_styles, leader.style_handle); for handle in [leader.text_style_handle, leader.context.text_style_handle] { - if let Some(name) = handle.and_then(|handle| text_style_names.get(&handle)) { + if let Some(name) = handle.and_then(|handle| text_style_names.get(&handle)) + { add(&mut index.text_styles, name); } } @@ -10472,11 +10396,7 @@ vis_index={:.1} visible_probe={:.1}", index } - fn dependency_targets( - &self, - kind: DependencyKind, - names: &[String], - ) -> DependencyTargets { + fn dependency_targets(&self, kind: DependencyKind, names: &[String]) -> DependencyTargets { if self.dependency_index_cache.borrow().is_none() { *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); } @@ -10730,7 +10650,13 @@ vis_index={:.1} visible_probe={:.1}", /// Resolve the active layout and annotation context for tessellation. fn tessellation_context( &self, - ) -> ([f32; 4], f32, Option, Arc, bool) { + ) -> ( + [f32; 4], + f32, + Option, + Arc, + bool, + ) { let is_model = self.current_layout == "Model"; let bg = if is_model { self.bg_color @@ -10763,8 +10689,7 @@ vis_index={:.1} visible_probe={:.1}", } fn tessellate_one(&self, e: &EntityType) -> Vec { - let (bg, anno, annotation_scale_handle, blk_cache, paper) = - self.tessellation_context(); + let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. tessellate_entity( @@ -10866,11 +10791,7 @@ vis_index={:.1} visible_probe={:.1}", continue; } for (index, &[x, y, z]) in wire.points.iter().enumerate() { - let [lx, ly, lz] = wire - .points_low - .get(index) - .copied() - .unwrap_or([0.0; 3]); + let [lx, ly, lz] = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); include(glam::DVec3::new( x as f64 + lx as f64, y as f64 + ly as f64, @@ -10909,13 +10830,8 @@ vis_index={:.1} visible_probe={:.1}", &self.document, &self.document.header.current_annotation_scale, ); - let wires = self.resident_wires_for( - model_block, - Some(self.annotation_scale), - scale, - None, - None, - ); + let wires = + self.resident_wires_for(model_block, Some(self.annotation_scale), scale, None, None); let mut min = [f64::INFINITY; 3]; let mut max = [f64::NEG_INFINITY; 3]; let mut has_points = false; @@ -10940,15 +10856,25 @@ vis_index={:.1} visible_probe={:.1}", continue; }; if !self.mesh_entity_visible(handle) - || !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block) + || !self.belongs_to_visible_block( + handle, + entity.common().owner_handle, + model_block, + ) { continue; } let [ax, ay, bx, by] = set.world_aabb; let [az, bz] = set.z_aabb; - let (ax, ay, bx, by, az, bz) = (ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64); - if (ax as f32).is_finite() && (ay as f32).is_finite() && (az as f32).is_finite() - && (bx as f32).is_finite() && (by as f32).is_finite() && (bz as f32).is_finite() + let (ax, ay, bx, by, az, bz) = ( + ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64, + ); + if (ax as f32).is_finite() + && (ay as f32).is_finite() + && (az as f32).is_finite() + && (bx as f32).is_finite() + && (by as f32).is_finite() + && (bz as f32).is_finite() { min[0] = min[0].min(ax.min(bx)); min[1] = min[1].min(ay.min(by)); @@ -11079,9 +11005,8 @@ mod section_tests { assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); - let (min, max) = bounds( - Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap(), - ); + let (min, max) = + bounds(Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap()); assert_near(min, [3.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); @@ -11097,20 +11022,17 @@ mod section_tests { #[test] fn instance_transform_pulls_the_section_plane_into_body_space() { let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); - let transform = acadrust::types::Transform::from_scaling( - acadrust::types::Vector3::new(2.0, 1.0, 1.0), - ) - .then(&acadrust::types::Transform::from_translation( - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - )); + let transform = + acadrust::types::Transform::from_scaling(acadrust::types::Vector3::new(2.0, 1.0, 1.0)) + .then(&acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + )); let mut data = section(1, 0.0); for point in &mut data.vertices { point.x = 20.0; } - let (min, max) = bounds( - Scene::section_body(&body, &data, None, Some(&transform)).unwrap(), - ); + let (min, max) = bounds(Scene::section_body(&body, &data, None, Some(&transform)).unwrap()); assert_near(min, [0.0, 0.0, 0.0]); assert_near(max, [5.0, 10.0, 10.0]); } @@ -11320,10 +11242,7 @@ mod journal_tests { s.invalidate_annotation_dependencies(); let annotation = as_map(s.replay_since(before_annotation)).unwrap(); assert_eq!(annotation.len(), 1); - assert_eq!( - annotation.get(&annotative), - Some(&ChangeKind::Modified) - ); + assert_eq!(annotation.get(&annotative), Some(&ChangeKind::Modified)); } #[test] @@ -11378,9 +11297,9 @@ mod journal_tests { /// A line has tangent geometry but does not feed the analytical uploads. #[test] fn the_partition_membership_test_separates_lines_from_curves() { + use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; use acadrust::entities::{Circle, Line}; use acadrust::types::Vector3; - use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; let mut scene = Scene::new(); let feeds = |scene: &Scene, handle: Handle| { @@ -11469,14 +11388,32 @@ mod journal_tests { add(&mut scene, 1.0); // Build the index, then add one more so the next call folds. let block = scene.model_space_block_handle(); - let before = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let before = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); let third = add(&mut scene, 2.0); - let folded = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let folded = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); scene.block_members_cache.borrow_mut().take(); - let rebuilt = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let rebuilt = scene + .block_members() + .1 + .get(&block) + .cloned() + .unwrap_or_default(); - assert_eq!(folded, rebuilt, "the folded index must equal a full rebuild"); + assert_eq!( + folded, rebuilt, + "the folded index must equal a full rebuild" + ); assert_eq!( folded.last(), Some(&third), @@ -11553,10 +11490,8 @@ mod journal_tests { scene.document.layers.add_or_replace(hidden); let mut on_layer = |layer: &str| { - let mut line = Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ); + let mut line = + Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)); line.common.layer = layer.to_string(); scene.add_entity(EntityType::Line(line)) }; @@ -11829,10 +11764,7 @@ mod journal_tests { // Replay the same Added delta history Undo emits. The entity must return // to its original physical range instead of appending/shifting the set. - let restored = s.apply_entity_delta( - &[(h2, Some(Arc::clone(&h2_image)), None)], - true, - ); + let restored = s.apply_entity_delta(&[(h2, Some(Arc::clone(&h2_image)), None)], true); for &(handle, _) in &restored { s.reseed_derived_caches(handle); } @@ -11841,8 +11773,7 @@ mod journal_tests { assert_eq!(restored_names, before_erase_names); assert!(s.resident_wire_sets.borrow().values().any(|set| { set.layout.as_ref().is_some_and(|layout| { - layout.ranges.get(&h2).map(|range| range.0) - == Some(original_h2_start) + layout.ranges.get(&h2).map(|range| range.0) == Some(original_h2_start) && !layout.vacant.contains_key(&h2) }) })); @@ -11972,15 +11903,21 @@ mod journal_tests { let cached_epoch = s.geometry_epoch; s.erase_entities(&[line]); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { - s.hatches.contains_key(&h) - })); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { - s.images.contains_key(&h) - })); - assert!(s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { - s.meshes.contains_key(&h) - })); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_HATCH, |h| { + s.hatches.contains_key(&h) + }) + ); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_IMAGE, |h| { + s.images.contains_key(&h) + }) + ); + assert!( + s.category_cache_valid(cached_epoch, CACHE_CATEGORY_MESH, |h| { + s.meshes.contains_key(&h) + }) + ); assert!(s.text_unchanged(cached_epoch)); } } @@ -12146,7 +12083,8 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); + let (entities, objects, _parametric_constraints, _named_parameters) = + rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); assert_eq!(objects.len(), 1); @@ -12170,7 +12108,8 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _sketch_constraints, _named_parameters) = rec.into_recorded_images(); + let (entities, objects, _parametric_constraints, _named_parameters) = + rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); assert_eq!(objects.len(), 1); @@ -12222,7 +12161,12 @@ mod redraw_tests { ViewportInstance { handle: Handle::NULL, tile_idx: Some(idx), - screen_rect: iced::Rectangle { x: 0.0, y: 0.0, width: 1.0, height: 1.0 }, + screen_rect: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, camera: Camera::default(), render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, active, @@ -12241,13 +12185,28 @@ mod redraw_tests { scene.request_refresh(ViewportRefreshScope::Active); let active_id = scene.instance_id_for(&tile_inst(0, true)); assert!(scene.refresh_pending_any(), "a request must be pending"); - assert!(scene.refresh_consume(active_id), "active tile id must be consumed"); - assert!(!scene.refresh_consume(active_id), "second consume for the same id is empty (one-shot per id)"); - assert!(!scene.refresh_pending_any(), "id set empty after its only target consumed it"); + assert!( + scene.refresh_consume(active_id), + "active tile id must be consumed" + ); + assert!( + !scene.refresh_consume(active_id), + "second consume for the same id is empty (one-shot per id)" + ); + assert!( + !scene.refresh_pending_any(), + "id set empty after its only target consumed it" + ); // REDRAW never touches geometry / block epochs. - assert_eq!(scene.geometry_epoch, geom_before, "REDRAW must not bump geometry_epoch"); - assert_eq!(scene.block_epoch, block_before, "REDRAW must not bump block_epoch"); + assert_eq!( + scene.geometry_epoch, geom_before, + "REDRAW must not bump geometry_epoch" + ); + assert_eq!( + scene.block_epoch, block_before, + "REDRAW must not bump block_epoch" + ); } #[test] @@ -12257,13 +12216,22 @@ mod redraw_tests { scene.request_refresh(ViewportRefreshScope::Active); assert!(scene.refresh_consume(t0), "Active targeted tile 0"); - assert!(!scene.refresh_pending_any(), "Active consumed: set now empty"); + assert!( + !scene.refresh_pending_any(), + "Active consumed: set now empty" + ); // A subsequent All must re-establish membership even after the Active // request was fully drained (the superset is resolved at request time). scene.request_refresh(ViewportRefreshScope::All); - assert!(scene.refresh_pending_any(), "All re-pends after Active was consumed"); - assert!(scene.refresh_consume(t0), "All must re-force tile 0 after Active drained"); + assert!( + scene.refresh_pending_any(), + "All re-pends after Active was consumed" + ); + assert!( + scene.refresh_consume(t0), + "All must re-force tile 0 after Active drained" + ); } #[test] @@ -12274,7 +12242,10 @@ mod redraw_tests { // FxHashSet design): every EXISTING tile id must be targeted and drained. scene.split_active_pane(false); // 2 tiles scene.split_active_pane(false); // 3 tiles - assert!(scene.model_tiles.borrow().len() >= 3, "split must have produced tiles"); + assert!( + scene.model_tiles.borrow().len() >= 3, + "split must have produced tiles" + ); scene.request_refresh(ViewportRefreshScope::All); for idx in 0..scene.model_tiles.borrow().len() { @@ -12284,10 +12255,16 @@ mod redraw_tests { "All must target EXISTING tile {idx}, not just tile 0" ); } - assert!(!scene.refresh_pending_any(), "All drained every existing tile"); + assert!( + !scene.refresh_pending_any(), + "All drained every existing tile" + ); // A tile index that does not exist must NOT be drained. let ghost = scene.instance_id_for(&tile_inst(99, false)); - assert!(!scene.refresh_consume(ghost), "nonexistent tile 99 has no membership"); + assert!( + !scene.refresh_consume(ghost), + "nonexistent tile 99 has no membership" + ); } } @@ -12301,7 +12278,8 @@ mod selection_arc_tests { let tn = std::any::type_name_of_val(&scene.selection); assert!( tn.contains("Arc"), - "selection should be Arc, got {}", tn + "selection should be Arc, got {}", + tn ); } @@ -12429,12 +12407,36 @@ mod layout_cache_tests { // Min X = 0.0, Max X = 400.0 // Min Y = -200.0, Max Y = 100.0 // Min Z = 0.0, Max Z = 20.0 - assert!((bounds.0.x - 0.0).abs() < 1e-3, "min.x mismatch: {}", bounds.0.x); - assert!((bounds.1.x - 400.0).abs() < 1e-3, "max.x mismatch: {}", bounds.1.x); - assert!((bounds.0.y - (-200.0)).abs() < 1e-3, "min.y mismatch: {}", bounds.0.y); - assert!((bounds.1.y - 100.0).abs() < 1e-3, "max.y mismatch: {}", bounds.1.y); - assert!((bounds.0.z - 0.0).abs() < 1e-3, "min.z mismatch: {}", bounds.0.z); - assert!((bounds.1.z - 20.0).abs() < 1e-3, "max.z mismatch: {}", bounds.1.z); + assert!( + (bounds.0.x - 0.0).abs() < 1e-3, + "min.x mismatch: {}", + bounds.0.x + ); + assert!( + (bounds.1.x - 400.0).abs() < 1e-3, + "max.x mismatch: {}", + bounds.1.x + ); + assert!( + (bounds.0.y - (-200.0)).abs() < 1e-3, + "min.y mismatch: {}", + bounds.0.y + ); + assert!( + (bounds.1.y - 100.0).abs() < 1e-3, + "max.y mismatch: {}", + bounds.1.y + ); + assert!( + (bounds.0.z - 0.0).abs() < 1e-3, + "min.z mismatch: {}", + bounds.0.z + ); + assert!( + (bounds.1.z - 20.0).abs() < 1e-3, + "max.z mismatch: {}", + bounds.1.z + ); } #[test] @@ -12495,13 +12497,17 @@ mod layout_cache_tests { let l_h = s.add_entity(EntityType::Line(line)); // Create block and place insert at (200, 300, 0) - let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new(200.0, 300.0, 0.0)); - let _ = s.create_block_from_entities( - &[c_h, a_h, l_h], - "TEST_ANALYTICAL_BLOCK", - &acadrust::types::Transform::identity(), - &xform, - ).unwrap(); + let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new( + 200.0, 300.0, 0.0, + )); + let _ = s + .create_block_from_entities( + &[c_h, a_h, l_h], + "TEST_ANALYTICAL_BLOCK", + &acadrust::types::Transform::identity(), + &xform, + ) + .unwrap(); let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); let depths = rustc_hash::FxHashMap::default(); diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index e5aed1d5d..85e40c6d7 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -24,10 +24,7 @@ pub enum PropValue { /// Editable text plus a dropdown of existing options (block reference /// Name row): picking an option re-points the reference, submitting a /// new name renames the definition. - EditChoice { - value: String, - options: Vec, - }, + EditChoice { value: String, options: Vec }, /// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker. ColorChoice(AcadColor), /// Color-book color with its file-provided display name. @@ -51,7 +48,10 @@ pub enum PropValue { BoolToggle { field: &'static str, value: bool }, /// A 0-based index navigated with ◀ / ▶ buttons (e.g. a polyline's Current /// Vertex). `display` is the label shown between the arrows (e.g. "2 / 7"). - Stepper { field: &'static str, display: String }, + Stepper { + field: &'static str, + display: String, + }, /// Hatch pattern name — rendered as a combo_box from the catalog. HatchPatternChoice(String), /// Block attribute value keyed by its (dynamic, runtime) tag — rendered as @@ -59,11 +59,14 @@ pub enum PropValue { /// tag carried here, not the row's `&'static str` field. AttrText { tag: String, value: String }, /// A clickable link to one or more entities — the Constraints section's - /// row for one persistent sketch constraint. Clicking it selects every + /// row for one parametric constraint. Clicking it selects every /// entity in `handles` in the viewport. `conflicting` mirrors - /// `SketchConstraintSet::conflicts`, tinting the row the same danger + /// `ParametricConstraintSet::conflicts`, tinting the row the same danger /// color the constraint's glyph pill already uses. - EntityLink { handles: Vec, conflicting: bool }, + EntityLink { + handles: Vec, + conflicting: bool, + }, /// One row of the document-wide named-parameter table (Parameters /// section, shown when nothing is selected). `index` is the row's /// position in `ParameterTable::iter()` order — stable across edits to diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0de4ad2e7..47b605db8 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,9 +9,7 @@ struct TextOrient { x_scale: f64, } -fn inverse_affine( - transform: &acadrust::types::Transform, -) -> Option { +fn inverse_affine(transform: &acadrust::types::Transform) -> Option { use acadrust::types::{Matrix3, Matrix4, Transform, Vector3}; let matrix = &transform.matrix.m; let linear = Matrix3::from_rows( @@ -248,9 +246,7 @@ impl Scene { let image_bearing = self.document.get_entity(h).is_some_and(|entity| { matches!( entity, - EntityType::RasterImage(_) - | EntityType::Ole2Frame(_) - | EntityType::Underlay(_) + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) ) }); if image_bearing { @@ -301,10 +297,7 @@ impl Scene { /// Entities in anonymous dimension blocks that visually belong to /// dimensions inside `block_record`. They must follow a block-coordinate /// reframe and be included in BEDIT's discard snapshot. - pub(crate) fn block_definition_dependent_handles( - &self, - block_record: Handle, - ) -> Vec { + pub(crate) fn block_definition_dependent_handles(&self, block_record: Handle) -> Vec { let Some(record) = self .document .block_records @@ -367,17 +360,16 @@ impl Scene { let owned: HashSet = handles.iter().copied().collect(); let transform = EntityTransform::Affine(*local_from_old); let matrix = &local_from_old.matrix.m; - let dependent_transform = - EntityTransform::Affine(acadrust::types::Transform::from_matrix( - acadrust::types::Matrix4 { - m: [ - [matrix[0][0], matrix[0][1], matrix[0][2], 0.0], - [matrix[1][0], matrix[1][1], matrix[1][2], 0.0], - [matrix[2][0], matrix[2][1], matrix[2][2], 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - }, - )); + let dependent_transform = EntityTransform::Affine(acadrust::types::Transform::from_matrix( + acadrust::types::Matrix4 { + m: [ + [matrix[0][0], matrix[0][1], matrix[0][2], 0.0], + [matrix[1][0], matrix[1][1], matrix[1][2], 0.0], + [matrix[2][0], matrix[2][1], matrix[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }, + )); let mut changed = Vec::new(); for handle in handles { @@ -596,10 +588,7 @@ impl Scene { let entity = self.document.get_entity(h)?.clone(); let annotation_scales = if leader_pair_handles.contains(&h) { - crate::scene::annotative::annotation_scale_handles_for_entity( - &self.document, - h, - ) + crate::scene::annotative::annotation_scale_handles_for_entity(&self.document, h) } else { Vec::new() }; @@ -691,8 +680,8 @@ impl Scene { if let Some(model) = new_model { self.hatches.insert(h, model); } - let rebuilt_history = self.copy_solid_history(src_handle, h) - && self.transform_solid_history(h, t); + let rebuilt_history = + self.copy_solid_history(src_handle, h) && self.transform_solid_history(h, t); if !rebuilt_history && self.document.get_entity(h).is_some_and(|entity| { matches!( @@ -717,8 +706,7 @@ impl Scene { let leader_links: Vec<(Handle, Handle)> = handle_map .iter() .filter_map(|(&source_handle, &copied_handle)| { - let EntityType::Leader(source_leader) = - self.document.get_entity(source_handle)? + let EntityType::Leader(source_leader) = self.document.get_entity(source_handle)? else { return None; }; @@ -733,9 +721,7 @@ impl Scene { .collect(); for (leader_handle, annotation_handle) in leader_links { - if let Some(EntityType::Leader(leader)) = - self.document.get_entity_mut(leader_handle) - { + if let Some(EntityType::Leader(leader)) = self.document.get_entity_mut(leader_handle) { leader.annotation_handle = annotation_handle; } @@ -753,7 +739,7 @@ impl Scene { .collect(); self.bump_entities(&changes); // Constraints wholly within the duplicated selection follow it. - self.duplicate_sketch_constraints_for(&handle_map); + self.duplicate_parametric_constraints_for(&handle_map); self.refresh_meshes_for_handles(&refresh_solid_handles); new_handles } @@ -840,11 +826,7 @@ impl Scene { let Some(reference) = reference else { return; }; - let point = acadrust::types::Vector3::new( - reference.x, - reference.y, - reference.z, - ); + let point = acadrust::types::Vector3::new(reference.x, reference.y, reference.z); match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point, Some(EntityType::Surface(entity)) => entity.point_of_reference = point, @@ -928,7 +910,9 @@ impl Scene { (EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => { let EntityType::Surface(surface) = crate::scene::model::loft_command_model::surface_entity(value) - else { return None; }; + else { + return None; + }; Some(Some(surface.surface_data)) } (EntityType::Solid3D(_), _) => Some(None), @@ -953,9 +937,11 @@ impl Scene { let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) - | acadrust::objects::SolidHistoryOperation::Extrusion(_)) - && !display.0.complete + if matches!( + &operation, + acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_) + ) && !display.0.complete { return false; } @@ -970,7 +956,8 @@ impl Scene { match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { - entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + entity.acis_data = + acadrust::entities::AcisData::from_sat(&document.to_sat_string()); if let Some(data) = surface_data { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { entity.kind = acadrust::entities::SurfaceKind::Extruded; @@ -1001,16 +988,19 @@ impl Scene { let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) - | acadrust::objects::SolidHistoryOperation::Extrusion(_)) - && !display.0.complete + if matches!( + &operation, + acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_) + ) && !display.0.complete { return false; } match self.document.get_entity_mut(handle) { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { - entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); + entity.acis_data = + acadrust::entities::AcisData::from_sat(&document.to_sat_string()); if let Some(data) = surface_data { if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { entity.kind = acadrust::entities::SurfaceKind::Extruded; @@ -1062,24 +1052,22 @@ impl Scene { true } - fn transform_solid_history( - &mut self, - handle: Handle, - transform: &EntityTransform, - ) -> bool { + fn transform_solid_history(&mut self, handle: Handle, transform: &EntityTransform) -> bool { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; - if !crate::scene::model::solid_history::transform_operation( - &mut operation, - transform, - ) { + if !crate::scene::model::solid_history::transform_operation(&mut operation, transform) { return false; } if let EntityTransform::Translate(delta) = transform { let surface_data = match (&operation, self.document.get_entity(handle)) { - (acadrust::objects::SolidHistoryOperation::Extrusion(value), Some(EntityType::Surface(_))) => { - let Some(data) = crate::scene::model::solid_history::extrusion_surface_data(value) else { + ( + acadrust::objects::SolidHistoryOperation::Extrusion(value), + Some(EntityType::Surface(_)), + ) => { + let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + else { return false; }; Some(data) @@ -1104,12 +1092,7 @@ impl Scene { self.rebuild_solid_history(handle, operation) } - pub fn apply_solid_history_choice( - &mut self, - handle: Handle, - field: &str, - value: &str, - ) -> bool { + pub fn apply_solid_history_choice(&mut self, handle: Handle, field: &str, value: &str) -> bool { self.record_solid_history_before(handle); let mut created = false; if self.document.solid_history_graph(handle).is_none() { @@ -1137,7 +1120,10 @@ impl Scene { _ => return false, }; if create { - if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_))) { + if !matches!( + self.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) { return false; } self.restore_solid_models(&[handle]); @@ -1195,13 +1181,9 @@ impl Scene { return false; }; self.restore_solid_models(&[handle]); - let Some(definition) = self - .solid_models - .get(&handle) - .and_then(|body| { - crate::scene::model::solid_history::fillet_radius_grip(body, radius) - }) - else { + let Some(definition) = self.solid_models.get(&handle).and_then(|body| { + crate::scene::model::solid_history::fillet_radius_grip(body, radius) + }) else { return false; }; let Some(axis) = definition.axis else { @@ -1247,8 +1229,7 @@ impl Scene { let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { return false; }; - let distance = if grip_id - == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + let distance = if grip_id == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 { &mut value.base_distance } else { @@ -1260,11 +1241,8 @@ impl Scene { } return self.preview_solid_history(handle, operation); } - if !crate::scene::model::solid_history::apply_primitive_grip( - &mut operation, - grip_id, - apply, - ) { + if !crate::scene::model::solid_history::apply_primitive_grip(&mut operation, grip_id, apply) + { return false; } self.preview_solid_history(handle, operation) @@ -1276,12 +1254,10 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = - crate::scene::model::solid_history::primitive_property_operation( - &self.document, - handle, - ) - else { + let Some(mut operation) = crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( @@ -1407,7 +1383,8 @@ impl Scene { }; let n = leader.vertices.len(); - if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() { + if n < 3 || (grip_id != n - 1 && grip_id != n - 2) || leader.annotation_handle.is_null() + { return None; } @@ -1451,10 +1428,7 @@ impl Scene { if self.sync_displayed_annotation_context(annotation_handle) { self.poison_undo_recording(); } - self.bump_entities(&[( - annotation_handle, - crate::scene::ChangeKind::Modified, - )]); + self.bump_entities(&[(annotation_handle, crate::scene::ChangeKind::Modified)]); } } } @@ -1510,9 +1484,7 @@ impl Scene { let image_bearing = self.document.get_entity(handle).is_some_and(|entity| { matches!( entity, - EntityType::RasterImage(_) - | EntityType::Ole2Frame(_) - | EntityType::Underlay(_) + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) ) }); diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index 3682af689..da6327802 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -2,14 +2,13 @@ //! Formulas are parsed locally so dependency cycles can be rejected before evaluation. use super::Scene; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; /// A parsed formula. Ordinary arithmetic plus [`Expr::Ref`] (a reference to /// another parameter by name) and [`Expr::Call`] (a function call). Only the /// small builtin set in `call_builtin` is accepted. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub enum Expr { Number(f64), Ref(String), @@ -57,7 +56,7 @@ impl Expr { /// stages, the UI) rather than panicking — matching this codebase's /// established pattern of isolating a bad derived value instead of letting /// it take down an unrelated computation (e.g. dangling-constraint handling -/// in `sketch_solve.rs`). +/// in `parametric_solve.rs`). #[derive(Debug, Clone, PartialEq)] pub enum ParamError { /// A formula's text doesn't parse. The `String` is a short, human- @@ -131,7 +130,7 @@ pub fn is_valid_name(name: &str) -> bool { } /// A dimensional constraint's literal or named driving value. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub enum DrivingValue { Literal(f64), Named(String), @@ -458,7 +457,7 @@ pub fn parse(source: &str) -> Result { /// One named parameter: a name plus its formula, both the original typed /// text (`source`, for redisplay/re-editing) and the parsed [`Expr`] /// (`expr`, for evaluation). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct Parameter { pub name: String, pub source: String, @@ -468,11 +467,11 @@ pub struct Parameter { /// Every named parameter for one document. /// /// `parameters` is intentionally not `pub` (unlike the sibling -/// `SketchConstraintSet::constraints`): this table's entire value is that +/// `ParametricConstraintSet::constraints`): this table's entire value is that /// every stored formula is already known to be parse-valid and cycle-free, /// an invariant only `set`/`remove` maintain. A direct external push could /// silently violate it. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ParameterTable { parameters: Vec, } @@ -932,20 +931,6 @@ mod tests { assert_eq!(table.resolve("a"), Ok(2.0)); } - #[test] - fn table_round_trips_through_bincode() { - let mut table = ParameterTable::new(); - table.set("hole_dia", "5").unwrap(); - table.set("hole_spacing", "2 * hole_dia + 1.5").unwrap(); - - let bytes = bincode::serialize(&table).expect("serialize"); - let restored: ParameterTable = bincode::deserialize(&bytes).expect("deserialize"); - - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("hole_spacing"), Ok(11.5)); - assert_eq!(restored.get("hole_dia").unwrap().source, "5"); - } - #[test] fn iter_preserves_insertion_order_across_redefinitions() { let mut table = ParameterTable::new(); diff --git a/src/scene/named_parameters_persist.rs b/src/scene/named_parameters_persist.rs deleted file mode 100644 index 986827ccb..000000000 --- a/src/scene/named_parameters_persist.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Persists the drawing-wide named-parameter table in a versioned XRecord. - -use super::named_parameters::ParameterTable; -use super::Scene; -use bincode::Options; - -const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS"; - -/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG -/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX` -/// bytes, so a table past a handful of parameters must be split across -/// multiple same-code `310` entries on write and concatenated back on read. -const MAX_CHUNK_BYTES: usize = u8::MAX as usize; -const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; - -/// Prefixed onto the serialized blob so a future schema change can be -/// detected and gracefully skipped rather than silently misreading bytes — -/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the -/// migration path itself is still undesigned. -const FORMAT_VERSION: u8 = 1; - -fn encode(table: &ParameterTable) -> Option> { - let mut bytes = vec![FORMAT_VERSION]; - let body = bincode::serialize(table).ok()?; - if body.len() >= MAX_RECORD_BYTES { - return None; - } - bytes.extend(body); - Some(bytes) -} - -fn decode(bytes: &[u8]) -> Option { - let (&version, body) = bytes.split_first()?; - if version != FORMAT_VERSION { - return None; - } - bincode::DefaultOptions::new() - .with_fixint_encoding() - .allow_trailing_bytes() - .with_limit(MAX_RECORD_BYTES as u64) - .deserialize(body) - .ok() -} - -impl Scene { - /// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS` - /// XRecord, ready for whatever save call happens next to serialize - /// `self.document` as-is. Called from the same save entry points as - /// `materialize_sketch_constraints_for_save` - /// (`OpenCADStudio::prepare_native_save`, the wasm save path). - /// - /// An empty table is skipped rather than writing an empty XRecord — an - /// unconstrained-by-parameters drawing (the common case, at least until - /// this feature is used) should not gain persisted-but-empty - /// bookkeeping on every save. - pub(crate) fn materialize_named_parameters_for_save(&mut self) { - if self.named_parameters.is_empty() { - return; - } - let Some(bytes) = encode(&self.named_parameters) else { - return; - }; - let owner = self.document.header.model_space_block_handle; - if owner.is_null() { - return; - } - self.document.ensure_xrecord(owner, XRECORD_KEY); - if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { - // Overwrite, not append: a resave must replace the prior blob, - // not accumulate more Chunk entries every time. - record.entries.clear(); - for chunk in bytes.chunks(MAX_CHUNK_BYTES) { - record.entries.push(acadrust::objects::XRecordEntry::new( - 310, - acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), - )); - } - } - } - - /// Populates `named_parameters` from the just-loaded `self.document`'s - /// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a - /// document open installs its `CadDocument` into this `Scene`, the same - /// call sites `load_sketch_constraints_from_document` already has - /// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"` - /// ops). Replaces whatever was already in `named_parameters` (a fresh - /// table for a real open; `"new"`'s blank document has nothing to find - /// anyway). - pub(crate) fn load_named_parameters_from_document(&mut self) { - self.named_parameters = ParameterTable::new(); - let owner = self.document.header.model_space_block_handle; - if owner.is_null() { - return; - } - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { - return; - }; - // Concatenate every Chunk entry in order, not just the first — see - // `MAX_CHUNK_BYTES`'s doc comment. - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; - } - bytes.extend_from_slice(chunk); - } - } - if bytes.is_empty() { - return; - } - if let Some(table) = decode(&bytes) { - self.named_parameters = table; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_parameter_table_survives_materialize_and_reload() { - let mut scene = Scene::new(); - scene.named_parameters_mut().set("hole_dia", "5").unwrap(); - scene - .named_parameters_mut() - .set("hole_spacing", "2 * hole_dia + 1.5") - .unwrap(); - - scene.materialize_named_parameters_for_save(); - // Simulate the load-time side of a save/reload round trip: drop the - // in-memory table (a fresh open starts empty) and repopulate purely - // from what materialize just wrote into `document.objects`. - scene.named_parameters = ParameterTable::new(); - scene.load_named_parameters_from_document(); - - assert_eq!(scene.named_parameters().len(), 2); - assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5)); - assert_eq!( - scene.named_parameters().get("hole_dia").unwrap().source, - "5" - ); - } - - /// The above test only proves `materialize`/`load` agree with each - /// other on the same in-memory `CadDocument` — this drives a real - /// `save_to_bytes`/`load_bytes` round trip (the same primitives - /// `OpenCADStudio::on_file_opened`'s native save/open path uses), - /// through both supported formats, mirroring - /// `sketch_persist`'s own real-bytes tests. - fn full_bytes_roundtrip(ext: &str) -> ParameterTable { - let mut scene = Scene::new(); - scene - .named_parameters_mut() - .set("plate_len", "42.5") - .unwrap(); - scene - .named_parameters_mut() - .set("plate_width", "plate_len / 2") - .unwrap(); - - scene.materialize_named_parameters_for_save(); - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = - crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - reloaded_scene.load_named_parameters_from_document(); - - reloaded_scene.named_parameters - } - - #[test] - fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dxf"); - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("plate_width"), Ok(21.25)); - } - - #[test] - fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dwg"); - assert_eq!(restored.len(), 2); - assert_eq!(restored.resolve("plate_width"), Ok(21.25)); - } - - #[test] - fn an_empty_table_is_not_materialized() { - let mut scene = Scene::new(); - scene.materialize_named_parameters_for_save(); - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_none(), - "an empty table should not create an XRecord" - ); - } - - #[test] - fn decode_rejects_a_mismatched_format_version() { - let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; - bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap()); - assert!( - decode(&bytes).is_none(), - "a future/unknown format version must be rejected, not misread" - ); - } -} diff --git a/src/scene/sketch_constraints.rs b/src/scene/parametric_constraints.rs similarity index 63% rename from src/scene/sketch_constraints.rs rename to src/scene/parametric_constraints.rs index 661c9ab9a..fe55c5dc4 100644 --- a/src/scene/sketch_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1,8 +1,7 @@ -//! Persistent sketch-constraint data types and scope management. +//! Runtime parametric-constraint data and scope management. use super::named_parameters::DrivingValue; use acadrust::types::{Handle, Vector3}; -use serde::{Deserialize, Serialize}; /// One endpoint a constraint attaches to: an entity plus which sub-element /// of it. @@ -14,11 +13,11 @@ use serde::{Deserialize, Serialize}; /// [`dimension_assoc::source_points`](super::dimension_assoc::source_points)'s /// ordered per-entity-type point list when non-negative (0/1 = a line's /// start/end, ...), or names a special case when negative (-3 = a -/// circle/arc's center; -2 = a point at a stored parameter, not currently -/// used by constraint endpoints but reserved for consistency with the -/// dimension scheme). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SketchRef { +/// circle/arc's center; -2 = a bounded curve's midpoint). Polyline segment +/// and segment-midpoint references use private negative ranges so they stay +/// distinct from vertex markers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ParametricRef { pub entity: Handle, /// `None` addresses the entity as a whole — what a Radius, Length, or /// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal, @@ -28,8 +27,9 @@ pub struct SketchRef { } const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; +const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; -impl SketchRef { +impl ParametricRef { pub fn whole(entity: Handle) -> Self { Self { entity, @@ -64,9 +64,23 @@ impl SketchRef { pub fn segment_index(self) -> Option { let marker = self.marker?; - (marker <= POLYLINE_SEGMENT_MARKER_BASE) + (marker <= POLYLINE_SEGMENT_MARKER_BASE && marker > POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) .then(|| (POLYLINE_SEGMENT_MARKER_BASE - marker) as usize) } + + /// Select the midpoint of one straight polyline segment. + pub fn segment_midpoint(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32), + } + } + + pub fn segment_midpoint_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - marker) as usize) + } } /// The friendly, user-facing constraint types — the "what button did they @@ -76,7 +90,7 @@ impl SketchRef { /// tools are (`crate::modules::parametric::tools`), plus the /// endpoint-picking kinds (`Coincident`, `Radius`, `Tangent`) that one-shot /// group never needed. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum ConstraintKind { Coincident, Horizontal, @@ -91,12 +105,14 @@ pub enum ConstraintKind { /// selected entity. Distance, Angle, + /// Angle defined by two rays sharing the middle point. + Angle3Point, Radius, Tangent, - /// An open spline endpoint remains curvature-continuous with another curve endpoint. + /// An open spline endpoint remains curvature-continuous with another bounded curve endpoint. Smooth, /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. - /// Solves identically to `Coincident` (`sketch_solve.rs` broadens that + /// Solves identically to `Coincident` (`parametric_solve.rs` broadens that /// match arm rather than duplicating it) — only the DWG-native class /// name (`ACCONCENTRICCONSTRAINT` vs `ACPOINTCOINCIDENCECONSTRAINT`) /// and the UI entry point differ. @@ -111,7 +127,7 @@ pub enum ConstraintKind { Midpoint, /// Locks a whole entity at its current position — `refs`: `[whole(entity)]`. /// No `driving_param`: the target is the entity's own live geometry at - /// solve time, not a typed value (see `sketch_solve.rs`'s `Fixed` arm). + /// solve time, not a typed value (see `parametric_solve.rs`'s `Fixed` arm). Fixed, /// A point lies anywhere along a line's or circle's curve (not /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. @@ -139,6 +155,9 @@ pub enum ConstraintKind { /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction. DistanceX, DistanceY, + /// Signed point-to-point distance along an arbitrary fixed direction, + /// or along a direction derived from a third line reference. + DistanceDirected, /// A line perpendicular to a circle/arc's tangent at their point of /// contact — `refs`: `[whole(a), whole(b)]`, either order. For the /// Line/Circle-only entity model this is equivalent to "the line @@ -148,43 +167,68 @@ pub enum ConstraintKind { /// `Perpendicular` (line-to-line only). Line-Line has no meaning here /// (that's plain `Perpendicular`) and isn't buildable. Normal, - /// An arc's arc length (`radius * sweep angle`) — `refs`: - /// `[whole(arc)]`. Solves against the arc's own `start_angle`/ - /// `end_angle`, registered separately from its center/radius - /// (`sketch_solve.rs`'s `arc_angles` cache) — no native DWG - /// representation exists for this (neither `AcExplicitConstr.h` nor - /// `acadrust`'s `AssocConstraintNodeData` has an ArcLength-shaped - /// class/variant), so it persists in this app's own XRecord format - /// only, the same "DWG can't carry everything" gap the dependency-chain - /// DXF-omission precedent already documents. - ArcLength, + /// A standard rigid geometry container. Its referenced points keep their + /// original relative distances while the whole set may translate or rotate. + RigidSet, } pub type ConstraintId = u32; -/// One persisted constraint record: a friendly [`ConstraintKind`], the +pub(crate) mod distance_direction_type { + pub const UNDIRECTED: u8 = 0; + pub const FIXED: u8 = 1; + pub const PARALLEL_TO_LINE: u8 = 2; + pub const PERPENDICULAR_TO_LINE: u8 = 3; +} + +pub(crate) mod angle_sector { + pub const PARALLEL_COUNTERCLOCKWISE: u8 = 0; + pub const ANTIPARALLEL_CLOCKWISE: u8 = 1; + pub const PARALLEL_CLOCKWISE: u8 = 2; + pub const ANTIPARALLEL_COUNTERCLOCKWISE: u8 = 3; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NativeConstraintOrigin { + pub(crate) group: Handle, + pub(crate) node: i32, +} + +/// One runtime constraint: a friendly [`ConstraintKind`], the /// entities/points it relates, and — for a dimensional kind — the value /// driving it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SketchConstraint { +#[derive(Debug, Clone, PartialEq)] +pub struct ParametricConstraint { pub id: ConstraintId, pub kind: ConstraintKind, - pub refs: Vec, + pub refs: Vec, /// The target for a dimensional constraint (a `Distance`'s length, an /// `Angle`'s degrees, a `Radius`'s radius) — a literal number or a /// named-parameter reference resolved through `Scene::named_parameters` at solve time by - /// `sketch_solve::build_constraint`). `None` for every purely-geometric + /// `parametric_solve::build_constraint`). `None` for every purely-geometric /// kind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, /// Equal, Tangent). pub driving_param: Option, /// Lets a user suppress a constraint without losing it — a re-solve /// skips a disabled constraint entirely. pub enabled: bool, + /// Standard graph node retained in place because its group also contains + /// graph features that are not safe to rebuild independently. + pub(crate) native_origin: Option, + /// Original point positions for a standard rigid set. This is rebuilt + /// from the associative graph on open and never stored separately. + pub(crate) rigid_points: Vec<(ParametricRef, Vector3)>, + /// Standard distance direction mode and vector. A third entry in `refs` + /// identifies the direction line for the line-relative modes. + pub(crate) distance_direction_type: u8, + pub(crate) distance_direction: Option, + /// Which of the four directed sectors an angular constraint measures. + pub(crate) angle_sector: u8, } /// A constraint scope: model space or one block definition. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum SketchScope { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParametricScope { ModelSpace, /// A block definition's `BlockRecord` handle — matches /// `BlockEditSession::br_handle` @@ -192,49 +236,55 @@ pub enum SketchScope { Block(Handle), } -impl SketchScope { +impl ParametricScope { /// The owner handle under which this scope is persisted. pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle { match self { - SketchScope::ModelSpace => document.header.model_space_block_handle, - SketchScope::Block(handle) => *handle, + ParametricScope::ModelSpace => document.header.model_space_block_handle, + ParametricScope::Block(handle) => *handle, } } } -/// Every persisted constraint for one [`SketchScope`]. Solver state is rebuilt +/// Every constraint decoded from one standard graph scope. Solver state is rebuilt /// on demand and is not stored here. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SketchConstraintSet { - pub scope: SketchScope, - pub constraints: Vec, +#[derive(Debug, Clone)] +pub struct ParametricConstraintSet { + pub scope: ParametricScope, + pub constraints: Vec, next_id: ConstraintId, + /// Parameters owned by this standard block scope. Model-space and + /// command-created constraints continue to use the drawing parameter + /// table; block definitions need their own namespace because identical + /// parameter names may legitimately occur in different blocks. + pub(crate) local_parameters: super::named_parameters::ParameterTable, + pub(crate) retained_standard_groups: Vec, /// Cached total remaining degrees of freedom, summed across every /// independent solve partition in this scope — updated by - /// `sketch_solve::solve_scope` each time this set is resolved. `None` + /// `parametric_solve::solve_scope` each time this set is resolved. `None` /// until the first resolve (e.g. right after loading from disk, before /// any edit has touched this scope yet). The status badge reads this /// rather than recomputing it every frame. Not persisted: /// it's a derived cache, not real constraint state. - #[serde(skip)] pub dof: Option, /// Cached redundant/conflicting constraints found by the last resolve. /// Also a derived cache, not /// persisted; a `ConflictResolverPanel` reads this rather than calling /// `cadkernel_constraints::diagnosis::classify_redundant` itself. - #[serde(skip)] pub conflicts: Vec<( ConstraintId, cadkernel_constraints::diagnosis::RedundancyKind, )>, } -impl SketchConstraintSet { - pub fn new(scope: SketchScope) -> Self { +impl ParametricConstraintSet { + pub fn new(scope: ParametricScope) -> Self { Self { scope, constraints: Vec::new(), next_id: 0, + local_parameters: super::named_parameters::ParameterTable::new(), + retained_standard_groups: Vec::new(), dof: None, conflicts: Vec::new(), } @@ -244,17 +294,22 @@ impl SketchConstraintSet { pub fn add( &mut self, kind: ConstraintKind, - refs: Vec, + refs: Vec, driving_param: Option, ) -> ConstraintId { let id = self.next_id; self.next_id += 1; - self.constraints.push(SketchConstraint { + self.constraints.push(ParametricConstraint { id, kind, refs, driving_param, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }); id } @@ -266,12 +321,15 @@ impl SketchConstraintSet { self.constraints.len() != before } - pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> { + pub fn get(&self, id: ConstraintId) -> Option<&ParametricConstraint> { self.constraints.iter().find(|c| c.id == id) } /// Every enabled constraint referencing `entity`. - pub fn constraints_touching(&self, entity: Handle) -> impl Iterator { + pub fn constraints_touching( + &self, + entity: Handle, + ) -> impl Iterator { self.constraints .iter() .filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) @@ -289,14 +347,12 @@ impl SketchConstraintSet { } } -/// Resolves a [`SketchRef`] to its current world-space point, for building +/// Resolves a [`ParametricRef`] to its current world-space point, for building /// an `cadkernel_constraints` `ParamStore` from live document geometry — the constraint /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted /// to the marker conventions constraint endpoints actually use (whole-entity -/// `None`, an ordinary `source_points()` index, or the `-3` center case). -/// Returns `None` for a dangling reference (handle doesn't resolve) or a -/// marker this scheme doesn't (yet) support (e.g. `-2`, or an out-of-range -/// index). +/// `None`, an ordinary `source_points()` index, the `-3` center case, or a +/// bounded curve/segment midpoint). /// /// Solver-side registration reads raw entity fields directly. This helper is /// for UI-side consumers that need the current world-space position. @@ -308,6 +364,36 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio _ => None, }; } + if marker == -2 { + let curve = crate::entities::curve::entity_curve(entity)?; + if curve.is_closed() { + return None; + } + let point = curve.point_at(0.5); + return Some(Vector3::new(point[0], point[1], point[2])); + } + let segment = ParametricRef { + entity: Handle::NULL, + marker: Some(marker), + } + .segment_midpoint_index(); + if let Some(segment) = segment { + let points = super::dimension_assoc::source_points(entity); + let closed = match entity { + acadrust::EntityType::LwPolyline(polyline) => polyline.is_closed, + acadrust::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let a = *points.get(segment)?; + let b = if segment + 1 < points.len() { + points[segment + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + return Some((a + b) * 0.5); + } if marker < 0 { return None; } @@ -317,27 +403,27 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio } /// Below this squared distance (1e-6 world units), two points count as -/// already coincident for [`nearest_sketch_point`]'s purposes. +/// already coincident for [`nearest_parametric_point`]'s purposes. const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; /// Finds the addressable entity point nearest a snapped world position. /// Returns `None` when no point in the scope is within the coincidence /// tolerance. -pub(crate) fn nearest_sketch_point( +pub(crate) fn nearest_parametric_point( document: &acadrust::CadDocument, - scope: SketchScope, + scope: ParametricScope, world_point: Vector3, exclude: Option, -) -> Option { +) -> Option { let owner = scope.owner_handle(document); - let mut best: Option<(f64, SketchRef)> = None; + let mut best: Option<(f64, ParametricRef)> = None; let mut consider = |handle: Handle, marker: i32, point: Vector3| { let dx = point.x - world_point.x; let dy = point.y - world_point.y; let dz = point.z - world_point.z; let dist_sq = dx * dx + dy * dy + dz * dz; if dist_sq <= COINCIDENT_EPSILON_SQ && best.as_ref().is_none_or(|(d, _)| dist_sq < *d) { - best = Some((dist_sq, SketchRef::point(handle, marker))); + best = Some((dist_sq, ParametricRef::point(handle, marker))); } }; for candidate in document.entities() { @@ -375,6 +461,7 @@ impl ConstraintKind { ConstraintKind::Equal => "=", ConstraintKind::Distance => "↔", ConstraintKind::Angle => "∠", + ConstraintKind::Angle3Point => "∠₃", ConstraintKind::Radius => "R", ConstraintKind::Tangent => "T", ConstraintKind::Smooth => "G²", @@ -389,15 +476,16 @@ impl ConstraintKind { ConstraintKind::Diameter => "⌀", ConstraintKind::DistanceX => "↔ₓ", ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::DistanceDirected => "↗", ConstraintKind::Normal => "⊾", - ConstraintKind::ArcLength => "⌢", + ConstraintKind::RigidSet => "▣", } } } /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). -pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { +pub(crate) fn glyph_label(constraint: &ParametricConstraint) -> String { match (constraint.kind, &constraint.driving_param) { (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { format!("{} {value:.1}°", constraint.kind.glyph_symbol()) @@ -422,7 +510,7 @@ pub(crate) fn glyph_label(constraint: &SketchConstraint) -> String { /// World-space anchor and outward direction for a constraint glyph. pub(crate) fn glyph_placement( document: &acadrust::CadDocument, - constraint: &SketchConstraint, + constraint: &ParametricConstraint, ) -> Option<(Vector3, Vector3)> { let r = constraint.refs.first()?; let entity = document.get_entity(r.entity)?; @@ -434,19 +522,13 @@ pub(crate) fn glyph_placement( ) }; let line_normal = |line: &acadrust::entities::Line| { - let direction = Vector3::new( - -(line.end.y - line.start.y), - line.end.x - line.start.x, - 0.0, - ); + let direction = Vector3::new(-(line.end.y - line.start.y), line.end.x - line.start.x, 0.0); (direction.length_squared() > 1e-24) .then_some(direction) .unwrap_or(Vector3::UNIT_Y) }; match (entity, r.marker) { - (acadrust::EntityType::Line(line), None) => { - Some((line_midpoint(line), line_normal(line))) - } + (acadrust::EntityType::Line(line), None) => Some((line_midpoint(line), line_normal(line))), (acadrust::EntityType::Circle(circle), None | Some(-3)) => { let center = circle.center_wcs(); let anchor = circle.point_at_angle_wcs(0.0); @@ -486,52 +568,72 @@ pub(crate) fn glyph_placement( } impl super::Scene { - pub fn is_sketch_constraint_visible(&self, scope: SketchScope, id: ConstraintId) -> bool { - !self.hidden_sketch_constraints.contains(&(scope, id)) + pub fn is_parametric_constraint_visible( + &self, + scope: ParametricScope, + id: ConstraintId, + ) -> bool { + !self.hidden_parametric_constraints.contains(&(scope, id)) } - pub fn set_sketch_constraint_visibility( + pub fn set_parametric_constraint_visibility( &mut self, - scope: SketchScope, + scope: ParametricScope, handles: Option<&[Handle]>, dimensional: bool, visible: bool, ) -> usize { - let ids: Vec<_> = self.sketch_constraint_set(scope).into_iter() + let ids: Vec<_> = self + .parametric_constraint_set(scope) + .into_iter() .flat_map(|set| set.constraints.iter()) .filter(|constraint| constraint.driving_param.is_some() == dimensional) - .filter(|constraint| handles.is_none_or(|handles| constraint.refs.iter() - .any(|reference| handles.contains(&reference.entity)))) - .map(|constraint| constraint.id).collect(); + .filter(|constraint| { + handles.is_none_or(|handles| { + constraint + .refs + .iter() + .any(|reference| handles.contains(&reference.entity)) + }) + }) + .map(|constraint| constraint.id) + .collect(); for id in &ids { - if visible { self.hidden_sketch_constraints.remove(&(scope, *id)); } - else { self.hidden_sketch_constraints.insert((scope, *id)); } + if visible { + self.hidden_parametric_constraints.remove(&(scope, *id)); + } else { + self.hidden_parametric_constraints.insert((scope, *id)); + } } ids.len() } /// Infers relations already present in the selected geometry. - pub fn inferred_sketch_constraints( + pub fn inferred_parametric_constraints( &self, - scope: SketchScope, + scope: ParametricScope, handles: &[Handle], - ) -> Vec<(ConstraintKind, Vec)> { + ) -> Vec<(ConstraintKind, Vec)> { use cadkernel::geom2d::{ infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - SketchPrimitive, Tolerance, + ParametricPrimitive, Tolerance, }; let mut sources = Vec::new(); for handle in handles { let primitive = match self.document.get_entity(*handle) { - Some(acadrust::EntityType::Line(line)) => SketchPrimitive::Line(Line { - start: [line.start.x, line.start.y], end: [line.end.x, line.end.y], + Some(acadrust::EntityType::Line(line)) => ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], }), - Some(acadrust::EntityType::Circle(circle)) => SketchPrimitive::Circle(Circle { - centre: [circle.center.x, circle.center.y], radius: circle.radius, + Some(acadrust::EntityType::Circle(circle)) => ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, }), - Some(acadrust::EntityType::Arc(arc)) => SketchPrimitive::Arc(Arc { - centre: [arc.center.x, arc.center.y], radius: arc.radius, - start_angle: arc.start_angle, end_angle: arc.end_angle, + Some(acadrust::EntityType::Arc(arc)) => ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, }), _ => continue, }; @@ -542,54 +644,90 @@ impl super::Scene { ConstraintEndpoint::Start => 0, ConstraintEndpoint::End => 1, }; - let mut mapped: Vec<_> = infer_constraints( - &primitives, Tolerance::new(1e-6), 0.5_f64.to_radians(), - ).into_iter().map(|relation| match relation { - InferredConstraint::Coincident { first, first_endpoint, second, second_endpoint } => ( - ConstraintKind::Coincident, - vec![SketchRef::point(sources[first].0, marker(first_endpoint)), - SketchRef::point(sources[second].0, marker(second_endpoint))], - ), - InferredConstraint::Collinear { first, second } => ( - ConstraintKind::Colinear, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Concentric { first, second } => ( - ConstraintKind::Concentric, - vec![SketchRef::center(sources[first].0), SketchRef::center(sources[second].0)], - ), - InferredConstraint::Parallel { first, second } => ( - ConstraintKind::Parallel, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Perpendicular { first, second } => ( - ConstraintKind::Perpendicular, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - InferredConstraint::Horizontal { entity } => ( - ConstraintKind::Horizontal, vec![SketchRef::whole(sources[entity].0)], - ), - InferredConstraint::Vertical { entity } => ( - ConstraintKind::Vertical, vec![SketchRef::whole(sources[entity].0)], - ), - InferredConstraint::Tangent { first, second } => ( - ConstraintKind::Tangent, - vec![SketchRef::whole(sources[first].0), SketchRef::whole(sources[second].0)], - ), - }).collect(); - if let Some(existing) = self.sketch_constraint_set(scope) { - mapped.retain(|(kind, refs)| !existing.constraints.iter().any(|constraint| { - constraint.kind == *kind && (constraint.refs == *refs - || (constraint.refs.len() == 2 && refs.len() == 2 - && constraint.refs[0] == refs[1] && constraint.refs[1] == refs[0])) - })); + let mut mapped: Vec<_> = + infer_constraints(&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians()) + .into_iter() + .map(|relation| match relation { + InferredConstraint::Coincident { + first, + first_endpoint, + second, + second_endpoint, + } => ( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(sources[first].0, marker(first_endpoint)), + ParametricRef::point(sources[second].0, marker(second_endpoint)), + ], + ), + InferredConstraint::Collinear { first, second } => ( + ConstraintKind::Colinear, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Concentric { first, second } => ( + ConstraintKind::Concentric, + vec![ + ParametricRef::center(sources[first].0), + ParametricRef::center(sources[second].0), + ], + ), + InferredConstraint::Parallel { first, second } => ( + ConstraintKind::Parallel, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Perpendicular { first, second } => ( + ConstraintKind::Perpendicular, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + InferredConstraint::Horizontal { entity } => ( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(sources[entity].0)], + ), + InferredConstraint::Vertical { entity } => ( + ConstraintKind::Vertical, + vec![ParametricRef::whole(sources[entity].0)], + ), + InferredConstraint::Tangent { first, second } => ( + ConstraintKind::Tangent, + vec![ + ParametricRef::whole(sources[first].0), + ParametricRef::whole(sources[second].0), + ], + ), + }) + .collect(); + if let Some(existing) = self.parametric_constraint_set(scope) { + mapped.retain(|(kind, refs)| { + !existing.constraints.iter().any(|constraint| { + constraint.kind == *kind + && (constraint.refs == *refs + || (constraint.refs.len() == 2 + && refs.len() == 2 + && constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0])) + }) + }); } - mapped.retain(|(kind, refs)| self.validate_sketch_constraint(*kind, refs, None).is_ok()); + mapped.retain(|(kind, refs)| { + self.validate_parametric_constraint(*kind, refs, None) + .is_ok() + }); mapped } - pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { - let [first, second] = handles else { return None }; + pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { + let [first, second] = handles else { + return None; + }; let first_entity = self.document.get_entity(*first)?; let second_entity = self.document.get_entity(*second)?; let (spline_handle, spline, target_handle, target) = match (first_entity, second_entity) { @@ -597,9 +735,11 @@ impl super::Scene { (target, acadrust::EntityType::Spline(spline)) => (*second, spline, *first, target), _ => return None, }; - if spline.flags.closed || spline.flags.periodic { return None; } - let spline_points = super::dimension_assoc::source_points( - &acadrust::EntityType::Spline(spline.clone())); + if spline.flags.closed || spline.flags.periodic { + return None; + } + let spline_points = + super::dimension_assoc::source_points(&acadrust::EntityType::Spline(spline.clone())); let target_points = super::dimension_assoc::source_points(target); let spline_ends = [*spline_points.first()?, *spline_points.last()?]; let target_ends = [*target_points.first()?, *target_points.last()?]; @@ -607,36 +747,46 @@ impl super::Scene { for (source_marker, source) in spline_ends.iter().enumerate() { for (target_marker, target) in target_ends.iter().enumerate() { let distance = (*source - *target).length_squared(); - if distance < best.0 { best = (distance, source_marker, target_marker); } + if distance < best.0 { + best = (distance, source_marker, target_marker); + } } } Some(vec![ - SketchRef::point(spline_handle, best.1 as i32), - SketchRef::point(target_handle, best.2 as i32), + ParametricRef::point(spline_handle, best.1 as i32), + ParametricRef::point(target_handle, best.2 as i32), ]) } /// The constraint set for `scope`, if one has been created. - pub fn sketch_constraint_set(&self, scope: SketchScope) -> Option<&SketchConstraintSet> { - self.sketch_constraints.iter().find(|s| s.scope == scope) + pub fn parametric_constraint_set( + &self, + scope: ParametricScope, + ) -> Option<&ParametricConstraintSet> { + self.parametric_constraints + .iter() + .find(|s| s.scope == scope) } /// The constraint set for `scope`, creating an empty one on first use. - /// The `pub(crate)` `sketch_constraints` field itself stays private so + /// The `pub(crate)` `parametric_constraints` field itself stays private so /// nothing outside this module can end up with two sets for the same /// scope — this is the one way to reach a scope's set for both reading /// and mutating. - pub fn sketch_constraint_set_mut(&mut self, scope: SketchScope) -> &mut SketchConstraintSet { + pub fn parametric_constraint_set_mut( + &mut self, + scope: ParametricScope, + ) -> &mut ParametricConstraintSet { if let Some(index) = self - .sketch_constraints + .parametric_constraints .iter() .position(|s| s.scope == scope) { - &mut self.sketch_constraints[index] + &mut self.parametric_constraints[index] } else { - self.sketch_constraints - .push(SketchConstraintSet::new(scope)); - self.sketch_constraints.last_mut().expect("just pushed") + self.parametric_constraints + .push(ParametricConstraintSet::new(scope)); + self.parametric_constraints.last_mut().expect("just pushed") } } @@ -654,24 +804,28 @@ impl super::Scene { /// scope, then triggers a solve for the newly duplicated geometry the /// same way any other edit would. A no-op when `handle_map` is empty or /// nothing constrained was duplicated. - pub fn duplicate_sketch_constraints_for( + pub fn duplicate_parametric_constraints_for( &mut self, handle_map: &rustc_hash::FxHashMap, ) { if handle_map.is_empty() { return; } - let mut to_add: Vec<(usize, ConstraintKind, Vec, Option)> = - Vec::new(); - for (scope_index, set) in self.sketch_constraints.iter().enumerate() { + let mut to_add: Vec<( + usize, + ConstraintKind, + Vec, + Option, + )> = Vec::new(); + for (scope_index, set) in self.parametric_constraints.iter().enumerate() { for c in &set.constraints { if !c.enabled || !c.refs.iter().all(|r| handle_map.contains_key(&r.entity)) { continue; } - let new_refs: Vec = c + let new_refs: Vec = c .refs .iter() - .map(|r| SketchRef { + .map(|r| ParametricRef { entity: handle_map[&r.entity], marker: r.marker, }) @@ -685,7 +839,7 @@ impl super::Scene { let mut touched: Vec = Vec::new(); for (scope_index, kind, refs, driving_param) in to_add { touched.extend(refs.iter().map(|r| r.entity)); - self.sketch_constraints[scope_index].add(kind, refs, driving_param); + self.parametric_constraints[scope_index].add(kind, refs, driving_param); } touched.sort(); touched.dedup(); @@ -705,7 +859,7 @@ impl super::Scene { /// still needs `Scene::document.get_entity`. pub fn parameter_usage(&self, name: &str) -> Vec { let mut out = Vec::new(); - for set in &self.sketch_constraints { + for set in &self.parametric_constraints { for c in &set.constraints { let Some(DrivingValue::Named(n)) = &c.driving_param else { continue; @@ -732,7 +886,7 @@ impl super::Scene { /// result type. #[derive(Debug, Clone)] pub struct ParameterUsage { - pub scope: SketchScope, + pub scope: ParametricScope, pub constraint_id: ConstraintId, pub kind: ConstraintKind, pub entities: Vec, @@ -763,38 +917,43 @@ mod tests { .add_entity(acadrust::EntityType::Circle(circle)) .unwrap(); - let constraint = |reference| SketchConstraint { + let constraint = |reference| ParametricConstraint { id: 0, kind: ConstraintKind::Fixed, refs: vec![reference], driving_param: None, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::whole(h(1)))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::whole(h(1)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::point(h(1), 0))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::point(h(1), 0))).unwrap(); assert_eq!(anchor, Vector3::ZERO); assert_eq!(direction, Vector3::new(-5.0, 0.0, 0.0)); let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::center(h(2)))).unwrap(); + glyph_placement(&document, &constraint(ParametricRef::center(h(2)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(5.0, 0.0, 0.0)); } #[test] fn add_assigns_increasing_ids_and_get_finds_them() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let a = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], None, ); let b = set.add( ConstraintKind::Distance, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], Some(DrivingValue::Literal(25.0)), ); assert_ne!(a, b); @@ -807,13 +966,17 @@ mod tests { #[test] fn remove_drops_only_the_matching_id() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let a = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(h(2))], None, ); - let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None); assert!(set.remove(a)); assert!( !set.remove(a), @@ -825,15 +988,15 @@ mod tests { #[test] fn constraints_touching_finds_entity_regardless_of_marker() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], None, ); set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], None, ); @@ -846,10 +1009,10 @@ mod tests { #[test] fn constraints_touching_skips_disabled() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let id = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], None, ); set.constraints @@ -862,15 +1025,15 @@ mod tests { #[test] fn remove_all_touching_drops_every_constraint_referencing_the_entity() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); let coincident = set.add( ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], None, ); let horizontal_other = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], None, ); @@ -886,80 +1049,54 @@ mod tests { #[test] fn scope_owner_handle_resolves_block_directly() { let block_handle = h(42); - let scope = SketchScope::Block(block_handle); + let scope = ParametricScope::Block(block_handle); let doc = acadrust::CadDocument::new(); assert_eq!(scope.owner_handle(&doc), block_handle); } #[test] fn ref_center_constructor_matches_the_dash_three_convention() { - let r = SketchRef::center(h(7)); + let r = ParametricRef::center(h(7)); assert_eq!( r, - SketchRef { + ParametricRef { entity: h(7), marker: Some(-3) } ); } - #[test] - fn sketch_constraint_set_round_trips_through_bincode() { - let mut set = SketchConstraintSet::new(SketchScope::Block(h(5))); - set.add( - ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], - None, - ); - set.add( - ConstraintKind::Distance, - vec![SketchRef::whole(h(3))], - Some(DrivingValue::Literal(12.5)), - ); - - let bytes = bincode::serialize(&set).expect("serialize"); - let restored: SketchConstraintSet = bincode::deserialize(&bytes).expect("deserialize"); - - assert_eq!(restored.scope, set.scope); - assert_eq!(restored.constraints.len(), set.constraints.len()); - assert_eq!( - restored.constraints[1].driving_param, - Some(DrivingValue::Literal(12.5)) - ); - assert_eq!(restored.constraints[0].refs, set.constraints[0].refs); - } - #[test] fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { let mut scene = super::super::Scene::new(); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], Some(DrivingValue::Named("gap".to_string())), ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(2))], + vec![ParametricRef::whole(h(2))], Some(DrivingValue::Named("gap".to_string())), ); // Unrelated: a literal-driven constraint and one driven by a // different name must not show up. scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(3))], + vec![ParametricRef::whole(h(3))], Some(DrivingValue::Literal(5.0)), ); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], + vec![ParametricRef::point(h(4), 0), ParametricRef::point(h(4), 1)], Some(DrivingValue::Named("other".to_string())), ); @@ -984,15 +1121,15 @@ mod tests { let mut scene = super::super::Scene::new(); let block = h(99); scene - .sketch_constraint_set_mut(SketchScope::Block(block)) + .parametric_constraint_set_mut(ParametricScope::Block(block)) .add( ConstraintKind::Radius, - vec![SketchRef::whole(h(1))], + vec![ParametricRef::whole(h(1))], Some(DrivingValue::Named("r".to_string())), ); let usage = scene.parameter_usage("r"); assert_eq!(usage.len(), 1); - assert_eq!(usage[0].scope, SketchScope::Block(block)); + assert_eq!(usage[0].scope, ParametricScope::Block(block)); } #[test] @@ -1002,10 +1139,10 @@ mod tests { // entity (e.g. a line's own start and end) must list that entity // once, not twice. scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], Some(DrivingValue::Named("len".to_string())), ); let usage = scene.parameter_usage("len"); @@ -1029,26 +1166,28 @@ mod tests { ), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(first)], + vec![ParametricRef::whole(first)], None, ); - let inferred = scene.inferred_sketch_constraints( - SketchScope::ModelSpace, - &[first, second], - ); + let inferred = + scene.inferred_parametric_constraints(ParametricScope::ModelSpace, &[first, second]); assert!(!inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(first)] + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] })); assert!(inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(second)] + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(second)] })); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Coincident)); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Colinear)); + assert!(inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Coincident)); + assert!(inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Colinear)); } #[test] @@ -1060,28 +1199,28 @@ mod tests { Vector3::new(5.0, 0.0, 0.0), ), )); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); let geometric = set.add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); let dimensional = set.add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(5.0)), ); assert_eq!( - scene.set_sketch_constraint_visibility( - SketchScope::ModelSpace, + scene.set_parametric_constraint_visibility( + ParametricScope::ModelSpace, None, false, false, ), 1 ); - assert!(!scene.is_sketch_constraint_visible(SketchScope::ModelSpace, geometric)); - assert!(scene.is_sketch_constraint_visible(SketchScope::ModelSpace, dimensional)); + assert!(!scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, geometric)); + assert!(scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, dimensional)); } } diff --git a/src/scene/sketch_solve.rs b/src/scene/parametric_solve.rs similarity index 69% rename from src/scene/sketch_solve.rs rename to src/scene/parametric_solve.rs index 48bb5a530..20e9581bb 100644 --- a/src/scene/sketch_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1,16 +1,15 @@ -//! Builds and solves persistent two-dimensional sketch constraints through the geometry kernel. +//! Builds and solves two-dimensional parametric constraints through the geometry kernel. //! Unsupported entity shapes, invalid target values, and incompatible planes are skipped. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use acadrust::entities::EntityType; use acadrust::types::{Handle, Vector3}; use cadkernel_constraints::constraints::angle_distance::L2LAngle; -use cadkernel_constraints::constraints::circle_arc::{ - ArcLength, C2LDistance, P2CDistance, TangentCircumf, -}; +use cadkernel_constraints::constraints::bspline::{Coord as SplineCoord, PointOnBSpline}; +use cadkernel_constraints::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; use cadkernel_constraints::constraints::conic::{ EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, }; @@ -18,18 +17,20 @@ use cadkernel_constraints::constraints::curve_generic::CurveValue; use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, + ProjectedDistance, ProjectedDistanceAlongLine, }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ - Arc as GArc, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, Line as GLine, - Point as GPoint, + Arc as GArc, BSpline as GBSpline, Circle as GCircle, Conic as GConic, Ellipse as GEllipse, + Line as GLine, Point as GPoint, }; use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::system::System; use super::named_parameters::ParameterTable; -use super::sketch_constraints::{ - ConstraintId, ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef, +use super::parametric_constraints::{ + angle_sector, distance_direction_type, ConstraintId, ConstraintKind, ParametricConstraint, + ParametricConstraintSet, ParametricRef, }; use super::{ChangeKind, Scene}; @@ -39,6 +40,8 @@ use super::{ChangeKind, Scene}; enum EntityGeom { Point(GPoint), Line(GLine), + Ray(GLine), + XLine(GLine), Polyline { points: Rc>, straight: Rc>, @@ -52,18 +55,23 @@ enum EntityGeom { Arc(GArc), /// See `register_entity` for the entity-to-kernel parametrization. Ellipse(GEllipse), + Spline { + curve: GBSpline, + control_z: Rc>, + }, } impl EntityGeom { /// The `cadkernel_constraints::geo::Point` for a marker on this entity — `None` for /// a marker this entity type/value doesn't support (see - /// `sketch_constraints::resolve_point` for the same convention on the + /// `parametric_constraints::resolve_point` for the same convention on the /// read side). fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { (EntityGeom::Point(point), 0) => Some(*point), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), + (EntityGeom::Ray(l), 0) => Some(l.p1), (EntityGeom::Polyline { points, .. }, marker) if marker >= 0 => { points.get(marker as usize).copied() } @@ -72,6 +80,8 @@ impl EntityGeom { (EntityGeom::Arc(a), 1) => Some(a.end), (EntityGeom::Arc(a), -3) => Some(a.circle.center), (EntityGeom::Ellipse(e), -3) => Some(e.center), + (EntityGeom::Spline { curve, .. }, 0) if !curve.periodic => Some(curve.start), + (EntityGeom::Spline { curve, .. }, 1) if !curve.periodic => Some(curve.end), _ => None, } } @@ -111,13 +121,14 @@ enum CircleOrLine { Other, } -fn as_circle_or_line(g: EntityGeom, reference: SketchRef) -> CircleOrLine { +fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), - EntityGeom::Line(l) => CircleOrLine::Line(l), + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => CircleOrLine::Line(l), EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), EntityGeom::Point(_) => CircleOrLine::Other, + EntityGeom::Spline { .. } => CircleOrLine::Other, EntityGeom::Polyline { .. } => reference .segment_index() .and_then(|index| g.line_segment(index)) @@ -215,6 +226,30 @@ fn register_entity( let p2 = GPoint::new(sys.add_param(l.end.x, false), sys.add_param(l.end.y, false)); Some(EntityGeom::Line(GLine { p1, p2 })) } + EntityType::Ray(ray) => { + let p1 = GPoint::new( + sys.add_param(ray.base_point.x, false), + sys.add_param(ray.base_point.y, false), + ); + let through = ray.base_point + ray.direction; + let p2 = GPoint::new( + sys.add_param(through.x, false), + sys.add_param(through.y, false), + ); + Some(EntityGeom::Ray(GLine { p1, p2 })) + } + EntityType::XLine(line) => { + let p1 = GPoint::new( + sys.add_param(line.base_point.x, false), + sys.add_param(line.base_point.y, false), + ); + let through = line.base_point + line.direction; + let p2 = GPoint::new( + sys.add_param(through.x, false), + sys.add_param(through.y, false), + ); + Some(EntityGeom::XLine(GLine { p1, p2 })) + } EntityType::Circle(c) => { let center = GPoint::new( sys.add_param(c.center.x, false), @@ -291,11 +326,49 @@ fn register_entity( radmin, })) } + EntityType::Spline(spline) => { + let source = crate::entities::spline::nurbs3(spline)?; + let poles: Vec<_> = source + .control_points() + .iter() + .map(|point| { + GPoint::new( + sys.add_param(point[0], false), + sys.add_param(point[1], false), + ) + }) + .collect(); + let start = *poles.first()?; + let end = *poles.last()?; + let weights = source + .weights() + .iter() + .map(|weight| sys.add_param(*weight, true)) + .collect(); + Some(EntityGeom::Spline { + curve: GBSpline { + poles, + weights, + knots: source.knots().to_vec(), + start, + end, + degree: source.degree(), + periodic: source.periodicity(), + }, + control_z: Rc::new( + source + .control_points() + .iter() + .map(|point| point[2]) + .collect(), + ), + }) + } _ => None, } } -/// Resolves one [`SketchRef`] against the entity-geometry cache, registering +/// Resolves one [`ParametricRef`] against the entity-geometry cache, registering /// the entity on first use. `None` if the handle is dangling, isn't a /// supported entity type, or (for a point ref) uses a marker that entity /// type doesn't support. @@ -303,7 +376,7 @@ fn resolve_ref( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, - r: SketchRef, + r: ParametricRef, ) -> Option { if let Some(geometry) = cache.get(&r.entity) { return Some(geometry.clone()); @@ -313,6 +386,166 @@ fn resolve_ref( Some(geometry) } +/// Resolves a point reference and installs the kernel equations for standard +/// implicit midpoints. Endpoint and center markers reuse entity parameters; +/// midpoint markers are derived parameters so moving either parent geometry +/// or another constrained object recomputes them in the same solve. +fn resolve_constraint_point( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + reference: ParametricRef, +) -> Option { + let marker = reference.marker?; + let geometry = resolve_ref(document, sys, cache, reference)?; + if let Some(point) = geometry.point_for_marker(marker) { + return Some(point); + } + + let midpoint_line = reference + .segment_midpoint_index() + .and_then(|index| geometry.line_segment(index)) + .or_else(|| { + (marker == -2) + .then_some(&geometry) + .and_then(|geometry| match geometry { + EntityGeom::Line(line) => Some(*line), + _ => None, + }) + }); + if let Some(line) = midpoint_line { + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + let point = GPoint::new( + sys.add_param((x1 + x2) * 0.5, false), + sys.add_param((y1 + y2) * 0.5, false), + ); + sys.add_constraint(Rc::new(CenterOfGravity::new( + point.x, + vec![line.p1.x, line.p2.x], + vec![0.5, 0.5], + ))); + sys.add_constraint(Rc::new(CenterOfGravity::new( + point.y, + vec![line.p1.y, line.p2.y], + vec![0.5, 0.5], + ))); + return Some(point); + } + if marker != -2 { + return None; + } + + match geometry { + EntityGeom::Arc(arc) => { + let (start, end) = { + let store = sys.store(); + (store.get(arc.start_angle), store.get(arc.end_angle)) + }; + let parameter_value = if end < start { + (start + end + std::f64::consts::TAU) * 0.5 + } else { + (start + end) * 0.5 + }; + let end_parameter = if end < start { + let adjusted = sys.add_param(end + std::f64::consts::TAU, false); + let turn = sys.add_param(std::f64::consts::TAU, true); + sys.add_constraint(Rc::new(Difference::new(arc.end_angle, adjusted, turn))); + adjusted + } else { + arc.end_angle + }; + let parameter = sys.add_param(parameter_value, false); + sys.add_constraint(Rc::new(CenterOfGravity::new( + parameter, + vec![arc.start_angle, end_parameter], + vec![0.5, 0.5], + ))); + let point = { + let value = cadkernel_constraints::geo::Curve::value( + &arc, + sys.store(), + parameter_value, + 0.0, + None, + ); + GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)) + }; + let curve = Rc::new(arc); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + Some(point) + } + EntityGeom::Ellipse(ellipse) => { + let EntityType::Ellipse(source) = document.get_entity(reference.entity)? else { + return None; + }; + if source.is_full() { + return None; + } + let parameter_value = (source.start_parameter + source.end_parameter) * 0.5; + let value = cadkernel_constraints::geo::Curve::value( + &ellipse, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let parameter = sys.add_param(parameter_value, true); + let curve = Rc::new(ellipse); + sys.add_constraint(Rc::new(CurveValue::new( + point, + point.x, + curve.clone(), + parameter, + ))); + sys.add_constraint(Rc::new(CurveValue::new(point, point.y, curve, parameter))); + Some(point) + } + EntityGeom::Spline { curve, .. } if !curve.periodic => { + let from = curve.knots[curve.degree]; + let to = curve.knots[curve.poles.len()]; + let parameter_value = (from + to) * 0.5; + let value = cadkernel_constraints::geo::Curve::value( + &curve, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let parameter = sys.add_param(parameter_value, true); + sys.add_constraint(Rc::new(PointOnBSpline::new( + point.x, + parameter, + SplineCoord::X, + curve.clone(), + ))); + sys.add_constraint(Rc::new(PointOnBSpline::new( + point.y, + parameter, + SplineCoord::Y, + curve, + ))); + Some(point) + } + _ => None, + } +} + const PLANE_EPS: f64 = 1e-9; fn is_world_z(normal: acadrust::types::Vector3) -> bool { @@ -340,6 +573,16 @@ fn entity_plane_z(entity: &EntityType) -> Option { { line.start.z } + EntityType::Ray(ray) + if ray.direction.z.abs() <= PLANE_EPS && ray.base_point.z.is_finite() => + { + ray.base_point.z + } + EntityType::XLine(line) + if line.direction.z.abs() <= PLANE_EPS && line.base_point.z.is_finite() => + { + line.base_point.z + } EntityType::Circle(circle) if is_world_z(circle.normal) => circle.center.z, EntityType::Arc(arc) if is_world_z(arc.normal) => arc.center.z, EntityType::Ellipse(ellipse) @@ -347,7 +590,7 @@ fn entity_plane_z(entity: &EntityType) -> Option { { ellipse.center.z } - EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => { + EntityType::Spline(spline) => { let curve = crate::entities::spline::nurbs3(spline)?; let first = *curve.control_points().first()?; if !curve @@ -386,7 +629,7 @@ fn entity_plane_z(entity: &EntityType) -> Option { z.is_finite().then_some(z) } -fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRef]) -> bool { +fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[ParametricRef]) -> bool { let mut plane_z = None; for reference in refs { let Some(entity) = document.get_entity(reference.entity) else { @@ -403,22 +646,27 @@ fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRe plane_z.is_some() } -fn resolved_target(params: &ParameterTable, constraint: &SketchConstraint) -> Option { +fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) -> Option { let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; if !value.is_finite() { return None; } let must_be_positive = matches!( constraint.kind, - ConstraintKind::Distance - | ConstraintKind::Radius - | ConstraintKind::Diameter - | ConstraintKind::ArcLength + ConstraintKind::Distance | ConstraintKind::Radius | ConstraintKind::Diameter ); (!must_be_positive || value > 0.0).then_some(value) } -/// One [`SketchConstraint`]'s `cadkernel_constraints` construction: every system-level +fn oriented_distance(target: f64, current_projection: f64) -> f64 { + if target >= 0.0 && current_projection < 0.0 { + -target + } else { + target + } +} + +/// One [`ParametricConstraint`]'s `cadkernel_constraints` construction: every system-level /// constraint it contributes (most kinds contribute exactly one; a few — /// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two /// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, @@ -438,13 +686,21 @@ fn build_constraint( sys: &mut System, cache: &mut HashMap, params: &ParameterTable, - c: &SketchConstraint, + c: &ParametricConstraint, ) -> Vec> { if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); } - let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { + let whole_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + match geometry { + EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) => Some(line), + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?), + _ => None, + } + }; + let bounded_line = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { let geometry = resolve_ref(document, sys, cache, r)?; match geometry { EntityGeom::Line(line) => Some(line), @@ -453,19 +709,21 @@ fn build_constraint( } }; let whole_circle = - |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( + |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| match resolve_ref( document, sys, cache, r, )? { EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), EntityGeom::Point(_) | EntityGeom::Line(_) + | EntityGeom::Ray(_) + | EntityGeom::XLine(_) | EntityGeom::Polyline { .. } - | EntityGeom::Ellipse(_) => None, + | EntityGeom::Ellipse(_) + | EntityGeom::Spline { .. } => None, }; - let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| { - let marker = r.marker?; - resolve_ref(document, sys, cache, r)?.point_for_marker(marker) + let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + resolve_constraint_point(document, sys, cache, r) }; // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two @@ -474,7 +732,7 @@ fn build_constraint( // name and the UI entry point that produces their `refs` differ. let point_pair_equal = |sys: &mut System, cache: &mut HashMap<_, _>, - refs: &[SketchRef]| + refs: &[ParametricRef]| -> Vec> { let [a, b] = refs else { return Vec::new() }; let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) else { @@ -544,7 +802,9 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - if let (Some(la), Some(lb)) = (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) { + if let (Some(la), Some(lb)) = + (bounded_line(sys, cache, *a), bounded_line(sys, cache, *b)) + { return vec![Rc::new(EqualLineLength::new(lb, la))]; } if let (Some(EntityGeom::Ellipse(a)), Some(EntityGeom::Ellipse(b))) = ( @@ -601,7 +861,8 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(p), Some(l)) = (point_ref(sys, cache, *a), whole_line(sys, cache, *b)) else { + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), bounded_line(sys, cache, *b)) + else { return Vec::new(); }; vec![ @@ -628,7 +889,9 @@ fn build_constraint( return Vec::new(); }; match geom { - EntityGeom::Line(l) => vec![Rc::new(PointOnLine::new(p, l))], + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + vec![Rc::new(PointOnLine::new(p, l))] + } EntityGeom::Circle(circ) => { let zero = sys.add_param(0.0, true); vec![Rc::new(P2CDistance::new(circ, p, zero))] @@ -644,6 +907,32 @@ fn build_constraint( EntityGeom::Ellipse(ellipse) => { vec![Rc::new(PointOnEllipse::new(p, ellipse))] } + EntityGeom::Spline { curve, .. } => { + let (x, y) = { + let store = sys.store(); + (store.get(p.x), store.get(p.y)) + }; + let Some(EntityType::Spline(source)) = document.get_entity(b.entity) else { + return Vec::new(); + }; + let Some(source_curve) = crate::entities::spline::nurbs3(source) else { + return Vec::new(); + }; + let Some(z) = source_curve.control_points().first().map(|point| point[2]) + else { + return Vec::new(); + }; + let parameter = sys.add_param(source_curve.parameter_at([x, y, z]), false); + vec![ + Rc::new(PointOnBSpline::new( + p.x, + parameter, + SplineCoord::X, + curve.clone(), + )), + Rc::new(PointOnBSpline::new(p.y, parameter, SplineCoord::Y, curve)), + ] + } EntityGeom::Polyline { .. } => b .segment_index() .and_then(|index| geom.line_segment(index)) @@ -740,7 +1029,7 @@ fn build_constraint( }) .collect() } - EntityGeom::Line(l) => { + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { let (x1, y1, x2, y2) = { let store = sys.store(); ( @@ -817,6 +1106,27 @@ fn build_constraint( Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), ] } + EntityGeom::Spline { curve, .. } => { + let values: Vec<_> = curve + .poles + .iter() + .map(|point| { + let store = sys.store(); + (point, store.get(point.x), store.get(point.y)) + }) + .collect(); + values + .into_iter() + .flat_map(|(point, x, y)| { + [ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) + as Rc, + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) + as Rc, + ] + }) + .collect() + } } } ConstraintKind::Distance => { @@ -833,6 +1143,77 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(P2PDistance::new(pa, pb, target))] } + ConstraintKind::DistanceDirected => { + let Some((&a, rest)) = c.refs.split_first() else { + return Vec::new(); + }; + let Some((&b, direction_ref)) = rest.split_first() else { + return Vec::new(); + }; + let (Some(pa), Some(pb)) = (point_ref(sys, cache, a), point_ref(sys, cache, b)) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + if matches!( + c.distance_direction_type, + distance_direction_type::PARALLEL_TO_LINE + | distance_direction_type::PERPENDICULAR_TO_LINE + ) { + if let Some(line) = direction_ref + .first() + .and_then(|reference| whole_line(sys, cache, *reference)) + { + let current = { + let store = sys.store(); + let delta = [ + store.get(pb.x) - store.get(pa.x), + store.get(pb.y) - store.get(pa.y), + ]; + let axis = [ + store.get(line.p2.x) - store.get(line.p1.x), + store.get(line.p2.y) - store.get(line.p1.y), + ]; + let length = axis[0].hypot(axis[1]); + if length <= f64::EPSILON { + 0.0 + } else if c.distance_direction_type + == distance_direction_type::PERPENDICULAR_TO_LINE + { + delta[0] * (-axis[1] / length) + delta[1] * (axis[0] / length) + } else { + delta[0] * (axis[0] / length) + delta[1] * (axis[1] / length) + } + }; + let target = sys.add_param(oriented_distance(resolved, current), true); + return vec![Rc::new(ProjectedDistanceAlongLine::new( + pa, + pb, + target, + line, + c.distance_direction_type == distance_direction_type::PERPENDICULAR_TO_LINE, + ))]; + } + } + let Some(direction) = c.distance_direction else { + return Vec::new(); + }; + let current = { + let store = sys.store(); + let length = direction.x.hypot(direction.y); + if length <= f64::EPSILON { + 0.0 + } else { + (store.get(pb.x) - store.get(pa.x)) * direction.x / length + + (store.get(pb.y) - store.get(pa.y)) * direction.y / length + } + }; + let target = sys.add_param(oriented_distance(resolved, current), true); + ProjectedDistance::new(pa, pb, target, [direction.x, direction.y]) + .map(|constraint| vec![Rc::new(constraint) as Rc]) + .unwrap_or_default() + } ConstraintKind::Angle => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -845,9 +1226,44 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let angle = sys.add_param(resolved.to_radians(), true); + let angle = resolved.to_radians(); + let angle = match c.angle_sector { + angle_sector::ANTIPARALLEL_CLOCKWISE => std::f64::consts::PI - angle, + angle_sector::PARALLEL_CLOCKWISE => -angle, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE => std::f64::consts::PI + angle, + _ => angle, + }; + let angle = sys.add_param(angle, true); vec![Rc::new(L2LAngle::new(fixed, moving, angle))] } + ConstraintKind::Angle3Point => { + let [a, vertex, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(a), Some(vertex), Some(b)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *vertex), + point_ref(sys, cache, *b), + ) else { + return Vec::new(); + }; + let Some(resolved) = resolved_target(params, c) else { + return Vec::new(); + }; + let angle = resolved.to_radians(); + let angle = match c.angle_sector { + angle_sector::ANTIPARALLEL_CLOCKWISE => std::f64::consts::PI - angle, + angle_sector::PARALLEL_CLOCKWISE => -angle, + angle_sector::ANTIPARALLEL_COUNTERCLOCKWISE => std::f64::consts::PI + angle, + _ => angle, + }; + let angle = sys.add_param(angle, true); + vec![Rc::new(L2LAngle::new( + GLine { p1: vertex, p2: a }, + GLine { p1: vertex, p2: b }, + angle, + ))] + } ConstraintKind::Radius => { let Some(r) = c.refs.first() else { return Vec::new(); @@ -879,11 +1295,9 @@ fn build_constraint( let target = sys.add_param(resolved, true); vec![Rc::new(Equal::new(circle.rad, target, 0.5))] } - // Signed X-only/Y-only component of the distance between two - // points — `Difference::new(p1, p2, d)` is `p2 - p1 == d`, matching - // the native format's `ACDISTANCECONSTRAINT` with a fixed-direction - // vector rather than a distinct native class - // (`dwg_native_constraints.rs`). + // X-only/Y-only distance between two points. Positive standard + // dimension values are magnitudes, so retain the geometry's current + // orientation; an explicitly negative command value remains signed. ConstraintKind::DistanceX => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -895,7 +1309,11 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let target = sys.add_param(resolved, true); + let current = { + let store = sys.store(); + store.get(pb.x) - store.get(pa.x) + }; + let target = sys.add_param(oriented_distance(resolved, current), true); vec![Rc::new(Difference::new(pa.x, pb.x, target))] } ConstraintKind::DistanceY => { @@ -909,7 +1327,11 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; - let target = sys.add_param(resolved, true); + let current = { + let store = sys.store(); + store.get(pb.y) - store.get(pa.y) + }; + let target = sys.add_param(oriented_distance(resolved, current), true); vec![Rc::new(Difference::new(pa.y, pb.y, target))] } ConstraintKind::Tangent => { @@ -977,6 +1399,39 @@ fn build_constraint( // Smooth edits the endpoint controls through cadkernel's spatial // NURBS operation after the ordinary 2D system has settled. ConstraintKind::Smooth => Vec::new(), + ConstraintKind::RigidSet => { + let points: Vec<_> = c + .rigid_points + .iter() + .filter_map(|(reference, original)| { + point_ref(sys, cache, *reference).map(|point| (point, *original)) + }) + .collect(); + let Some(second) = (1..points.len()).find(|&index| { + let delta = points[index].1 - points[0].1; + delta.x * delta.x + delta.y * delta.y > 1.0e-18 + }) else { + return Vec::new(); + }; + let mut pairs = vec![(0, second)]; + for index in 1..points.len() { + if index == second { + continue; + } + pairs.push((0, index)); + pairs.push((second, index)); + } + pairs + .into_iter() + .map(|(a, b)| { + let delta = points[b].1 - points[a].1; + let target = + sys.add_param((delta.x * delta.x + delta.y * delta.y).sqrt(), true); + Rc::new(P2PDistance::new(points[a].0, points[b].0, target)) + as Rc + }) + .collect() + } // A circle's radius is always normal to its own tangent, so // "line normal to circle/arc" reduces to "line passes through the // circle's center" — the same `PointOnLine` primitive @@ -1002,19 +1457,6 @@ fn build_constraint( _ => Vec::new(), } } - ConstraintKind::ArcLength => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(EntityGeom::Arc(arc)) = resolve_ref(document, sys, cache, *r) else { - return Vec::new(); - }; - let Some(resolved) = resolved_target(params, c) else { - return Vec::new(); - }; - let target = sys.add_param(resolved, true); - vec![Rc::new(ArcLength::new(arc, target))] - } } } @@ -1042,7 +1484,7 @@ fn smooth_target_jet(entity: &EntityType, marker: i32) -> Option, ) { for constraint in set @@ -1120,16 +1562,21 @@ type SolveResult = ( /// on a successful solve. fn solve_scope( document: &acadrust::CadDocument, - params: &ParameterTable, - set: &SketchConstraintSet, + drawing_params: &ParameterTable, + set: &ParametricConstraintSet, ) -> Option { + let params = if set.local_parameters.is_empty() { + drawing_params + } else { + &set.local_parameters + }; let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); // Tracks which system-level `cadkernel_constraints` constraint(s) came from which - // `SketchConstraint` — a `SubSystem`'s redundant-row indices (from + // `ParametricConstraint` — a `SubSystem`'s redundant-row indices (from // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint // list, not `set.constraints`' indices, and several kinds contribute - // more than one system-level constraint per `SketchConstraint` (see + // more than one system-level constraint per `ParametricConstraint` (see // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after // partitioning resolves a row back to the `ConstraintId` the UI // actually names. @@ -1153,7 +1600,7 @@ fn solve_scope( // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, // unconditionally — the kernel adds these the moment an arc - // exists, not only when some `SketchConstraint` happens to reference + // exists, not only when some `ParametricConstraint` happens to reference // its endpoint. Not tracked in `owner`: these are solver-internal // bookkeeping, never redundant with anything a user-facing constraint // could name, so there's no `ConstraintId` for them to report against. @@ -1235,7 +1682,7 @@ fn solve_scope( .values() .map(|g| match g { EntityGeom::Point(_) => 2, - EntityGeom::Line(_) => 4, + EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, EntityGeom::Polyline { points, .. } => points.len() * 2, EntityGeom::Circle(_) => 3, // Raw param count (center×2, rad, start×2, end×2, both @@ -1247,6 +1694,7 @@ fn solve_scope( // same way any other constraint would. EntityGeom::Arc(_) => 9, EntityGeom::Ellipse(_) => 5, + EntityGeom::Spline { curve, .. } => curve.poles.len() * 2, }) .sum(); let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); @@ -1261,12 +1709,20 @@ fn solve_scope( if diag.redundant.is_empty() { continue; } - // Opt-in per `classify_redundant`'s own doc comment (one extra solve - // per redundant row) — only reached when a partition is actually - // over-constrained, which is rare, so this never costs anything on - // the common "no redundancy" path. + let mut seen = HashSet::new(); + let reportable: Vec<_> = diag + .redundant + .iter() + .copied() + .filter(|row| { + owner + .iter() + .find(|(rc, _)| Rc::ptr_eq(rc, &sub.constraints()[*row])) + .is_some_and(|(_, id)| seen.insert(*id)) + }) + .collect(); for (row, kind) in - cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) + cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &reportable) { let row_constraint = &sub.constraints()[row]; if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { @@ -1394,6 +1850,54 @@ fn solve_scope( results.push((handle, EntityType::Line(updated))); } } + (EntityType::Ray(ray), EntityGeom::Ray(g)) => { + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); + let length = (x2 - x1).hypot(y2 - y1); + let direction = if length > MOVE_EPS { + Vector3::new((x2 - x1) / length, (y2 - y1) / length, 0.0) + } else { + ray.direction + }; + if (x1 - ray.base_point.x).abs() > MOVE_EPS + || (y1 - ray.base_point.y).abs() > MOVE_EPS + || (direction - ray.direction).length() > MOVE_EPS + { + let mut updated = ray.clone(); + updated.base_point.x = x1; + updated.base_point.y = y1; + updated.direction = direction; + results.push((handle, EntityType::Ray(updated))); + } + } + (EntityType::XLine(line), EntityGeom::XLine(g)) => { + let (x1, y1, x2, y2) = ( + store.get(g.p1.x), + store.get(g.p1.y), + store.get(g.p2.x), + store.get(g.p2.y), + ); + let length = (x2 - x1).hypot(y2 - y1); + let direction = if length > MOVE_EPS { + Vector3::new((x2 - x1) / length, (y2 - y1) / length, 0.0) + } else { + line.direction + }; + if (x1 - line.base_point.x).abs() > MOVE_EPS + || (y1 - line.base_point.y).abs() > MOVE_EPS + || (direction - line.direction).length() > MOVE_EPS + { + let mut updated = line.clone(); + updated.base_point.x = x1; + updated.base_point.y = y1; + updated.direction = direction; + results.push((handle, EntityType::XLine(updated))); + } + } (EntityType::LwPolyline(polyline), EntityGeom::Polyline { points, .. }) => { let mut updated = polyline.clone(); let mut changed = false; @@ -1515,6 +2019,41 @@ fn solve_scope( results.push((handle, EntityType::Ellipse(updated))); } } + (EntityType::Spline(spline), EntityGeom::Spline { curve, control_z }) => { + let mut changed = + spline.fit_points.len() > 0 || spline.control_points.len() != curve.poles.len(); + let control_points: Vec<_> = curve + .poles + .iter() + .zip(control_z.iter()) + .enumerate() + .map(|(index, (point, z))| { + let next = Vector3::new(store.get(point.x), store.get(point.y), *z); + changed |= spline + .control_points + .get(index) + .is_none_or(|old| (next - *old).length() > MOVE_EPS); + next + }) + .collect(); + if changed { + let mut updated = spline.clone(); + updated.degree = curve.degree as i32; + updated.knots = curve.knots.clone(); + updated.control_points = control_points; + updated.weights = curve.weights.iter().map(|id| store.get(*id)).collect(); + updated.fit_points.clear(); + updated.begin_tangent = Vector3::ZERO; + updated.end_tangent = Vector3::ZERO; + updated.flags.rational = updated + .weights + .windows(2) + .any(|pair| (pair[0] - pair[1]).abs() > 1.0e-12); + updated.flags.closed = curve.periodic; + updated.flags.periodic = curve.periodic; + results.push((handle, EntityType::Spline(updated))); + } + } _ => {} } } @@ -1525,10 +2064,10 @@ fn solve_scope( impl Scene { /// Rejects constraints the 2D kernel bridge cannot represent before they /// are persisted or reported as applied. - pub(crate) fn validate_sketch_constraint( + pub(crate) fn validate_parametric_constraint( &self, kind: ConstraintKind, - refs: &[SketchRef], + refs: &[ParametricRef], driving_param: Option<&super::named_parameters::DrivingValue>, ) -> Result<(), &'static str> { if !refs_share_supported_plane(&self.document, refs) { @@ -1580,12 +2119,17 @@ impl Scene { } value => value.cloned(), }; - let candidate = SketchConstraint { + let candidate = ParametricConstraint { id: 0, kind, refs: refs.to_vec(), driving_param: validation_target, enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let mut system = System::new(); let mut cache = HashMap::new(); @@ -1607,7 +2151,7 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. - pub(crate) fn refresh_sketch_constraints( + pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], ) -> Vec<(Handle, ChangeKind)> { @@ -1619,7 +2163,7 @@ impl Scene { // // Recorded into the *same* undo transaction as the entity removal // that caused it (audit finding: ERASE undo silently lost constraint - // state, since `sketch_constraints` lives outside `document`/ + // state, since `parametric_constraints` lives outside `document`/ // `document.objects` and neither of those directories ever saw this // mutation) — one undo press now restores both the entity and its // constraints together. @@ -1627,24 +2171,24 @@ impl Scene { if *kind != ChangeKind::Removed { continue; } - for i in 0..self.sketch_constraints.len() { - if self.sketch_constraints[i] - .constraints_touching(*handle) - .next() - .is_some() + for i in 0..self.parametric_constraints.len() { + if self.parametric_constraints[i] + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|r| r.entity == *handle)) { - let scope = self.sketch_constraints[i].scope; - let before = self.sketch_constraints[i].clone(); - self.record_undo_sketch_constraints_before(scope, before); - self.sketch_constraints[i].remove_all_touching(*handle); + let scope = self.parametric_constraints[i].scope; + let before = self.parametric_constraints[i].clone(); + self.record_undo_parametric_constraints_before(scope, before); + self.parametric_constraints[i].remove_all_touching(*handle); } } } let mut result = Vec::new(); - for i in 0..self.sketch_constraints.len() { + for i in 0..self.parametric_constraints.len() { let touched = changes.iter().any(|(handle, _)| { - self.sketch_constraints[i] + self.parametric_constraints[i] .constraints_touching(*handle) .next() .is_some() @@ -1655,12 +2199,12 @@ impl Scene { let Some((solved, dof, conflicts)) = solve_scope( &self.document, &self.named_parameters, - &self.sketch_constraints[i], + &self.parametric_constraints[i], ) else { continue; }; - self.sketch_constraints[i].dof = Some(dof); - self.sketch_constraints[i].conflicts = conflicts; + self.parametric_constraints[i].dof = Some(dof); + self.parametric_constraints[i].conflicts = conflicts; for (handle, new_entity) in solved { if let Some(before) = self.document.get_entity_arc(handle) { self.record_undo_before(handle, Some(before)); @@ -1677,15 +2221,15 @@ impl Scene { /// Re-solves touched scopes during a grip drag without mutating the /// document or recording undo. The caller owns preview write-back and the /// final gesture history entry. - pub(crate) fn solve_sketch_constraints_preview( + pub(crate) fn solve_parametric_constraints_preview( &self, touched: &[Handle], ) -> Vec<(Handle, EntityType)> { - if self.sketch_constraints.is_empty() || touched.is_empty() { + if self.parametric_constraints.is_empty() || touched.is_empty() { return Vec::new(); } let mut result = Vec::new(); - for set in &self.sketch_constraints { + for set in &self.parametric_constraints { let is_touched = touched .iter() .any(|handle| set.constraints_touching(*handle).next().is_some()); @@ -1706,8 +2250,8 @@ impl Scene { #[cfg(test)] mod tests { use super::super::named_parameters::DrivingValue; - use super::super::sketch_constraints::{ - ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, + use super::super::parametric_constraints::{ + ConstraintKind, ParametricConstraintSet, ParametricRef, ParametricScope, }; use super::Scene; use acadrust::entities::EntityType; @@ -1720,9 +2264,9 @@ mod tests { acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), )); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Horizontal, - &[SketchRef::whole(circle)], + &[ParametricRef::whole(circle)], None ) .is_err()); @@ -1736,9 +2280,9 @@ mod tests { Vector3::new(1.0, 0.0, 1.0), ))); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Parallel, - &[SketchRef::whole(lower), SketchRef::whole(upper)], + &[ParametricRef::whole(lower), ParametricRef::whole(upper)], None, ) .is_err()); @@ -1751,23 +2295,23 @@ mod tests { Vector3::new(0.0, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0), ))); - let refs = [SketchRef::point(line, 0), SketchRef::point(line, 1)]; + let refs = [ParametricRef::point(line, 0), ParametricRef::point(line, 1)]; assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::Distance, &refs, Some(&DrivingValue::Literal(-1.0)), ) .is_err()); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::DistanceX, &refs, Some(&DrivingValue::Literal(-1.0)), ) .is_ok()); assert!(scene - .validate_sketch_constraint( + .validate_parametric_constraint( ConstraintKind::DistanceX, &refs, Some(&DrivingValue::Literal(f64::NAN)), @@ -1775,9 +2319,47 @@ mod tests { .is_err()); } + #[test] + fn ray_and_construction_line_constraints_solve_through_the_kernel() { + let mut scene = Scene::new(); + let ray = scene.add_entity(EntityType::Ray(acadrust::entities::Ray::new( + Vector3::ZERO, + Vector3::new(1.0, 1.0, 0.0), + ))); + let xline = scene.add_entity(EntityType::XLine(acadrust::entities::XLine::new( + Vector3::new(5.0, 5.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(ray)], + None, + ); + set.add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(xline)], + None, + ); + + scene.refresh_parametric_constraints(&[ + (ray, super::ChangeKind::Modified), + (xline, super::ChangeKind::Modified), + ]); + + let Some(EntityType::Ray(ray)) = scene.document.get_entity(ray) else { + panic!("expected ray") + }; + assert!(ray.direction.y.abs() < 1e-7); + let Some(EntityType::XLine(xline)) = scene.document.get_entity(xline) else { + panic!("expected construction line") + }; + assert!(xline.direction.x.abs() < 1e-7); + } + // The bulk of this stage's coverage (Horizontal/Parallel/Distance/ // Coincident solving, unrelated edits not triggering a resolve) lives - // in `tests/sketch_constraints_solve.rs`, which only needs `pub` API. + // in `tests/parametric_constraints_solve.rs`, which only needs `pub` API. // This one test needs `record_undo_before`/`take_undo_recording` // (`pub(crate)`), so it stays internal. #[test] @@ -1792,13 +2374,13 @@ mod tests { Vector3::new(10.0, 5.0, 0.0), ))); - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); - scene.sketch_constraints.push(set); + scene.parametric_constraints.push(set); scene.begin_undo_recording(); // A real command (Move, grip-drag commit, ...) records its own @@ -1813,7 +2395,7 @@ mod tests { .take_undo_recording() .expect("an undo recording should still be open"); - let (entities, _objects, _sketch_constraints, _named_parameters) = + let (entities, _objects, _parametric_constraints, _named_parameters) = recording.into_recorded_images(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); assert!( @@ -1840,15 +2422,15 @@ mod tests { Vector3::new(20.0, 5.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], None, ); assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), @@ -1861,7 +2443,7 @@ mod tests { // Ensure the test exercises restoration rather than a no-op. assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .map_or(0, |s| s.constraints.len()), 0, "the constraint touching the erased line should be gone from the live set" @@ -1870,15 +2452,15 @@ mod tests { let recording = scene .take_undo_recording() .expect("an undo recording should still be open"); - let (_entities, _objects, sketch_constraints, _named_parameters) = + let (_entities, _objects, parametric_constraints, _named_parameters) = recording.into_recorded_images(); assert_eq!( - sketch_constraints.len(), + parametric_constraints.len(), 1, "the touched scope's before-image must be captured" ); - let (scope, before) = &sketch_constraints[0]; - assert_eq!(*scope, SketchScope::ModelSpace); + let (scope, before) = ¶metric_constraints[0]; + assert_eq!(*scope, ParametricScope::ModelSpace); assert_eq!( before.constraints.len(), 1, @@ -1887,15 +2469,15 @@ mod tests { assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); assert_eq!( before.constraints[0].refs, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)] + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)] ); // What `apply_delta_state` does with this on undo: install the // before-image back as the live scope state. - *scene.sketch_constraint_set_mut(*scope) = before.clone(); + *scene.parametric_constraint_set_mut(*scope) = before.clone(); assert_eq!( scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(), @@ -1904,7 +2486,7 @@ mod tests { ); } - /// `solve_sketch_constraints_preview` reports the constrained + /// `solve_parametric_constraints_preview` reports the constrained /// neighbor's solved position without writing anything into the /// document itself — the grip-drag caller owns applying it. #[test] @@ -1918,13 +2500,13 @@ mod tests { Vector3::new(0.0, 5.0, 0.0), Vector3::new(10.0, 5.0, 0.0), ))); - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); - scene.sketch_constraints.push(set); + scene.parametric_constraints.push(set); // Simulate a live grip drag: mutate `a` directly (as `apply_grip` // would each frame), without going through `bump_entities` at all. @@ -1933,7 +2515,7 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_sketch_constraints_preview(&[a]); + let solved = scene.solve_parametric_constraints_preview(&[a]); assert_eq!( scene.document.get_entity(b), @@ -1969,6 +2551,132 @@ mod tests { ); } + #[test] + fn rigid_set_preserves_relative_geometry_after_one_member_moves() { + let mut scene = Scene::new(); + let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 5.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ))); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + set.add( + ConstraintKind::RigidSet, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + set.constraints[0].rigid_points = vec![ + (ParametricRef::point(a, 0), Vector3::new(0.0, 0.0, 0.0)), + (ParametricRef::point(a, 1), Vector3::new(10.0, 0.0, 0.0)), + (ParametricRef::point(b, 0), Vector3::new(0.0, 5.0, 0.0)), + (ParametricRef::point(b, 1), Vector3::new(10.0, 5.0, 0.0)), + ]; + scene.parametric_constraints.push(set); + + if let Some(EntityType::Line(line)) = scene.document.get_entity_mut(b) { + line.start.y += 10.0; + line.end.y += 10.0; + } + scene.bump_entities(&[(b, super::ChangeKind::Modified)]); + + let endpoints = |handle| { + let EntityType::Line(line) = scene.document.get_entity(handle).unwrap() else { + panic!("expected a line") + }; + (line.start, line.end) + }; + let (a0, a1) = endpoints(a); + let (b0, b1) = endpoints(b); + let distance = |first: Vector3, second: Vector3| { + let delta = second - first; + (delta.x * delta.x + delta.y * delta.y).sqrt() + }; + assert!((distance(a0, a1) - 10.0).abs() < 1.0e-6); + assert!((distance(b0, b1) - 10.0).abs() < 1.0e-6); + assert!((distance(a0, b0) - 5.0).abs() < 1.0e-6); + assert!((distance(a1, b1) - 5.0).abs() < 1.0e-6); + } + + #[test] + fn implicit_midpoint_recomputes_with_its_parent_line() { + let mut scene = Scene::new(); + let carrier = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + let anchor = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(7.0, 3.0, 0.0), + Vector3::new(7.0, 8.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(carrier, -2), + ParametricRef::point(anchor, 0), + ], + None, + ); + + scene.bump_entities(&[(carrier, super::ChangeKind::Modified)]); + + let EntityType::Line(line) = scene.document.get_entity(carrier).unwrap() else { + panic!("expected line"); + }; + let midpoint = (line.start + line.end) * 0.5; + assert!((midpoint - Vector3::new(7.0, 3.0, 0.0)).length() < 1.0e-7); + } + + #[test] + fn spline_endpoint_participates_in_the_kernel_constraint_system() { + let mut scene = Scene::new(); + let spline = scene.add_entity(EntityType::Spline( + acadrust::entities::Spline::from_control_points( + 3, + vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(2.0, 1.0, 0.0), + Vector3::new(4.0, 1.0, 0.0), + Vector3::new(6.0, 0.0, 0.0), + ], + ), + )); + let anchor = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(9.0, 4.0, 0.0), + Vector3::new(9.0, 7.0, 0.0), + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); + set.add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(spline, 1), + ParametricRef::point(anchor, 0), + ], + None, + ); + + scene.bump_entities(&[(anchor, super::ChangeKind::Modified)]); + + let EntityType::Spline(spline) = scene.document.get_entity(spline).unwrap() else { + panic!("expected spline"); + }; + let end = spline.control_points.last().copied().unwrap(); + assert!((end - Vector3::new(9.0, 4.0, 0.0)).length() < 1.0e-7); + } + #[test] fn smooth_constraint_recomputes_the_spline_after_its_target_moves() { let mut scene = Scene::new(); @@ -1987,10 +2695,13 @@ mod tests { spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); let spline = scene.add_entity(EntityType::Spline(spline)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Smooth, - vec![SketchRef::point(spline, 0), SketchRef::point(target, 0)], + vec![ + ParametricRef::point(spline, 0), + ParametricRef::point(target, 0), + ], None, ); diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs deleted file mode 100644 index 00a6404e7..000000000 --- a/src/scene/sketch_persist.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Persists each sketch constraint set in a versioned, chunked XRecord. - -use super::sketch_constraints::SketchConstraintSet; -use super::Scene; -use acadrust::{CadDocument, Handle}; -use bincode::Options; - -const XRECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS"; - -/// The vendored DWG writer's `XRecordValue::Chunk` encoder packs a single -/// `u8` length prefix per entry and silently truncates anything longer -/// (`cadcodec`'s `io/dwg/dwg_stream_writers/object_writer/objects.rs`, -/// `encode_xrecord_entries`) — unlike the DXF writer, which has no such -/// limit. A constraint set past a handful of entries serializes past 255 -/// bytes easily, so a single `XRecordEntry` silently loses the tail and -/// `bincode::deserialize` then fails on the truncated bytes, dropping the -/// *entire* scope's constraints on the next DWG load — reproduced and -/// root-caused live (not from a change log): a 5-entry set round-tripped -/// clean, a 6-entry set of the same shapes lost everything. Splitting the -/// blob across as many same-code `310` entries as needed (below) works -/// around it without touching the vendored crate — `CadDocument::xrecord`'s -/// `entries: Vec` already supports repeating a code. -const MAX_CHUNK_BYTES: usize = u8::MAX as usize; -const MAX_RECORD_BYTES: usize = 8 * 1024 * 1024; - -/// Removes a named XRecord from `owner`'s extension dictionary, if present. -/// The dictionary itself is left in place even if now empty — other code may -/// already reference it via `xdictionary_handle`, and an empty extension -/// dictionary is harmless bookkeeping, unlike a stale XRecord that would -/// resurrect deleted data on the next load. -fn remove_xrecord(document: &mut CadDocument, owner: Handle, key: &str) { - let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { - return; - }; - let Some(acadrust::objects::ObjectType::Dictionary(dictionary)) = - document.objects.get_mut(&dictionary_handle) - else { - return; - }; - let Some(index) = dictionary - .entries - .iter() - .position(|(name, _)| name.eq_ignore_ascii_case(key)) - else { - return; - }; - let (_, record_handle) = dictionary.entries.remove(index); - document.objects.remove(&record_handle); -} - -/// Serialized schema version. Unknown versions are skipped without affecting -/// the rest of the drawing. -const FORMAT_VERSION: u8 = 2; - -fn encode(set: &SketchConstraintSet) -> Option> { - let mut bytes = vec![FORMAT_VERSION]; - let body = bincode::serialize(set).ok()?; - if body.len() >= MAX_RECORD_BYTES { - return None; - } - bytes.extend(body); - Some(bytes) -} - -fn decode(bytes: &[u8]) -> Option { - let (&version, body) = bytes.split_first()?; - if version != FORMAT_VERSION { - return None; - } - bincode::DefaultOptions::new() - .with_fixint_encoding() - .allow_trailing_bytes() - .with_limit(MAX_RECORD_BYTES as u64) - .deserialize(body) - .ok() -} - -impl Scene { - /// Writes every current `sketch_constraints` entry into its scope's - /// owner-handle XRecord, ready for whatever save call happens next to - /// serialize `self.document` as-is. Called right before a native/web - /// save (`OpenCADStudio::prepare_native_save` and the wasm save path) - /// — see those call sites' doc comments for why "right before save" - /// rather than keeping the XRecord resident and live-updated (design - /// doc §5.2's "live" model, deferred: it would also let constraint-set - /// edits ride the ordinary object-delta undo path per §5.1(b), but this - /// lazy model was the doc's own recommended first step). - /// - /// A scope with an empty constraint set writes no XRecord — an - /// unconstrained drawing (the common case) should not gain - /// persisted-but-empty bookkeeping on every save. If a *previous* save - /// already materialized a non-empty XRecord for that scope, the now-empty - /// set actively removes it instead: leaving the stale blob in place would - /// resurrect the deleted constraints the next time the document loads. - pub(crate) fn materialize_sketch_constraints_for_save(&mut self) { - for index in 0..self.sketch_constraints.len() { - let set = &self.sketch_constraints[index]; - if set.constraints.is_empty() { - let owner = set.scope.owner_handle(&self.document); - if !owner.is_null() { - remove_xrecord(&mut self.document, owner, XRECORD_KEY); - } - continue; - } - let owner = set.scope.owner_handle(&self.document); - if owner.is_null() { - continue; - } - let Some(bytes) = encode(set) else { continue }; - self.document.ensure_xrecord(owner, XRECORD_KEY); - if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) { - // Overwrite, not append: a resave must replace the prior - // blob, not accumulate more Chunk entries every time. - record.entries.clear(); - for chunk in bytes.chunks(MAX_CHUNK_BYTES) { - record.entries.push(acadrust::objects::XRecordEntry::new( - 310, - acadrust::objects::XRecordValue::Chunk(chunk.to_vec()), - )); - } - } - } - } - - /// Populates `sketch_constraints` from every `OCS_SKETCH_CONSTRAINTS` - /// XRecord found in the just-loaded `self.document` — called right - /// after a document open installs its `CadDocument` into this `Scene` - /// (`OpenCADStudio::on_file_opened` and the automation `"open"`/`"new"` - /// ops). Every `BlockRecord` (model space, paper space, and named - /// block definitions) is a candidate owner; most will have no such - /// XRecord and are skipped cheaply. Replaces whatever was already in - /// `sketch_constraints` (a fresh/empty `Vec` for a real open; `"new"`'s - /// blank document has nothing to find anyway). - pub(crate) fn load_sketch_constraints_from_document(&mut self) { - self.sketch_constraints.clear(); - let owners: Vec = self - .document - .block_records - .iter() - .map(|record| record.handle) - .collect(); - for owner in owners { - if let Some(record) = self.document.xrecord(owner, XRECORD_KEY) { - let mut bytes = Vec::new(); - for entry in &record.entries { - if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value { - if bytes.len().saturating_add(chunk.len()) > MAX_RECORD_BYTES { - bytes.clear(); - break; - } - bytes.extend_from_slice(chunk); - } - } - if let Some(set) = decode(&bytes) { - self.sketch_constraints.push(set); - } - continue; - } - - if let Some(set) = super::dwg_native_constraints::native_constraint_set( - &self.document, - owner, - &mut self.named_parameters, - ) { - self.sketch_constraints.push(set); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::super::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; - use super::*; - use acadrust::entities::EntityType; - use acadrust::types::Vector3; - - #[test] - fn a_model_space_constraint_set_survives_materialize_and_reload() { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - - scene.materialize_sketch_constraints_for_save(); - // Simulate the load-time side of a save/reload round trip: drop the - // in-memory sets (a fresh open starts with none) and repopulate - // purely from what materialize just wrote into `document.objects`. - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - - let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) - .expect("constraint set should have been reloaded"); - assert_eq!(set.constraints.len(), 1); - assert_eq!(set.constraints[0].kind, ConstraintKind::Horizontal); - assert_eq!(set.constraints[0].refs, vec![SketchRef::whole(a)]); - } - - /// The above test only proves `materialize`/`load` agree with each - /// other on the same in-memory `CadDocument` — it never actually - /// serializes anything. This drives a real `save_to_bytes`/`load_bytes` - /// round trip (the same primitives `OpenCADStudio::on_file_opened`'s - /// native save/open path uses), through both supported formats, closing - /// the gap stage 2's spike proved for the raw XRecord mechanism up to - /// this stage's actual app-level wiring. - fn full_bytes_roundtrip(ext: &str) -> SketchConstraintSet { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 5.0, 0.0), - ))); - let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(10.0, 5.0, 0.0), - Vector3::new(20.0, 5.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], - None, - ); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Distance, - vec![SketchRef::whole(b)], - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)), - ); - - scene.materialize_sketch_constraints_for_save(); - let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let mut reloaded_scene = Scene::new(); - reloaded_scene.document = crate::io::load_bytes(&format!("stage4_roundtrip.{ext}"), bytes) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - reloaded_scene.load_sketch_constraints_from_document(); - - reloaded_scene - .sketch_constraint_set(SketchScope::ModelSpace) - .cloned() - .unwrap_or_else(|| { - panic!("no ModelSpace constraint set survived the {ext} save/load round trip") - }) - } - - #[test] - fn a_constraint_set_survives_a_real_dxf_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dxf"); - assert_eq!(restored.constraints.len(), 2); - assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!( - restored.constraints[1].driving_param, - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) - ); - } - - #[test] - fn a_constraint_set_survives_a_real_dwg_save_and_load_round_trip() { - let restored = full_bytes_roundtrip("dwg"); - assert_eq!(restored.constraints.len(), 2); - assert_eq!(restored.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!(restored.constraints[1].kind, ConstraintKind::Distance); - assert_eq!( - restored.constraints[1].driving_param, - Some(crate::scene::named_parameters::DrivingValue::Literal(12.5)) - ); - } - - #[test] - fn a_block_scoped_constraint_set_survives_materialize_and_reload() { - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ))); - // `create_block_from_entities` returns the placed INSERT's handle, - // not the BlockRecord's own handle — `SketchScope::Block` needs the - // latter (it's what `BlockEditSession::br_handle` and - // `SketchScope::owner_handle` both mean by "a block definition's - // handle"), so look it up from the table by name instead of using - // the return value directly. - let _insert_handle = scene - .create_block_from_entities( - &[a], - "OCS_STAGE4_TEST_BLOCK", - &acadrust::types::Transform::identity(), - &acadrust::types::Transform::identity(), - ) - .expect("create a block definition to own the constraint set"); - let br_handle = scene - .document - .block_records - .get("OCS_STAGE4_TEST_BLOCK") - .expect("block record should exist") - .handle; - scene - .sketch_constraint_set_mut(SketchScope::Block(br_handle)) - .add(ConstraintKind::Vertical, vec![SketchRef::whole(a)], None); - - scene.materialize_sketch_constraints_for_save(); - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - - let set = scene - .sketch_constraint_set(SketchScope::Block(br_handle)) - .expect("block-scoped constraint set should have been reloaded"); - assert_eq!(set.constraints.len(), 1); - assert_eq!(set.constraints[0].kind, ConstraintKind::Vertical); - } - - #[test] - fn an_empty_constraint_set_is_not_materialized() { - // A scope touched (e.g. `sketch_constraint_set_mut` called once, - // then every constraint removed) but left with zero constraints - // should not persist an empty XRecord. - let mut scene = Scene::new(); - let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - scene.materialize_sketch_constraints_for_save(); - - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_none(), - "an empty constraint set should not create an XRecord" - ); - } - - #[test] - fn clearing_a_previously_materialized_set_removes_its_stale_xrecord() { - // Regression for the audit-flagged case `an_empty_constraint_set_is_not_materialized` - // doesn't cover: a scope that already had a *non-empty* XRecord from a - // prior save must have that XRecord actively removed once its - // in-memory set goes back to empty — otherwise the stale blob - // survives the save and resurrects the deleted constraints on the - // next load. - let mut scene = Scene::new(); - let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ))); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - scene.materialize_sketch_constraints_for_save(); - - let owner = scene.document.header.model_space_block_handle; - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_some(), - "sanity: the first save should have materialized an XRecord" - ); - - // Clear every constraint (mirrors deleting the constrained entity, or - // removing the last constraint by hand) and save again. - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .constraints - .clear(); - scene.materialize_sketch_constraints_for_save(); - - assert!( - scene.document.xrecord(owner, XRECORD_KEY).is_none(), - "the stale XRecord from the earlier non-empty save must be removed, not left behind" - ); - - // And the load path must agree: nothing resurrects on reload. - scene.sketch_constraints.clear(); - scene.load_sketch_constraints_from_document(); - assert!( - scene - .sketch_constraint_set(SketchScope::ModelSpace) - .is_none(), - "no constraints should come back after the set was cleared and re-saved" - ); - } - - #[test] - fn decode_rejects_a_mismatched_format_version() { - let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)]; - bytes.extend( - bincode::serialize(&SketchConstraintSet::new(SketchScope::ModelSpace)).unwrap(), - ); - assert!( - decode(&bytes).is_none(), - "a future/unknown format version must be rejected, not misread" - ); - } -} diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index d37fb157b..52ec2916e 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -1,13 +1,11 @@ //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info +pub mod status_menu; pub mod statusbar_config; pub mod statusbar_menu; -pub mod status_menu; use iced::widget::tooltip::Position as TipPos; -use iced::widget::{ - button, column, container, mouse_area, row, text, text_input, tooltip, -}; +use iced::widget::{button, column, container, mouse_area, row, text, text_input, tooltip}; use iced::{Background, Border, Color, Element, Length, Theme}; use iced_aw::ContextMenu; use std::sync::Arc; @@ -23,10 +21,10 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; -use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; -use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; -use crate::ui::wrap_bar::WrapBar; use crate::t; +use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; +use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; +use crate::ui::wrap_bar::WrapBar; /// Height of one status-bar row. Matches the drawing-tab strip above it so the /// three horizontal strips — tabs, status bar, command line — line up, and it @@ -125,16 +123,16 @@ impl StatusBar { // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, menu_data: StatusMenuData<'a>, - // Remaining degrees of freedom for the current sketch scope. + // Remaining degrees of freedom for the current parametric scope. // `None` when the scope - // has no `SketchConstraintSet` yet (nothing constrained), so the + // has no `ParametricConstraintSet` yet (nothing constrained), so the // badge stays invisible until it's actually relevant. - sketch_dof: Option, + parametric_dof: Option, // Number of redundant/conflicting constraints in the current scope. // Zero hides // the pill entirely, so an ordinarily/fully-constrained drawing sees // no new clutter. - sketch_conflicts: usize, + parametric_conflicts: usize, // What is drawing the scene. Only a degraded verdict shows anything. gpu_status: &'a crate::scene::pipeline::GpuStatus, ) -> Element<'a, Message> { @@ -157,9 +155,7 @@ impl StatusBar { } else { crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0) }; - let menu_button = button(menu_icon) - .style(button::subtle) - .padding([4, 8]); + let menu_button = button(menu_icon).style(button::subtle).padding([4, 8]); let menu_btn = if is_start { tip( menu_button.into(), @@ -270,9 +266,9 @@ impl StatusBar { ); } // Not a `StatusPill` (so not user-hideable yet): invisible until the - // current scope actually has a SketchConstraintSet, so a drawing + // current scope actually has a ParametricConstraintSet, so a drawing // that never uses parametric constraints sees no new clutter. - if let Some(dof) = sketch_dof { + if let Some(dof) = parametric_dof { let pill = if dof == 0 { success_pill(crate::tf!("DOF: {dof}").into_owned()) } else { @@ -284,21 +280,21 @@ impl StatusBar { if dof == 0 { t!("Fully constrained — no remaining degrees of freedom") } else { - t!("Remaining degrees of freedom in the current sketch's persistent constraints") + t!("Remaining degrees of freedom in the current parametric constraint scope") }, ) .into(), ); } // Each click removes one flagged constraint. - if sketch_conflicts > 0 { + if parametric_conflicts > 0 { pills.push( tip( action_pill( - crate::tf!("⚠ {sketch_conflicts} conflicting").into_owned(), - Message::ResolveOneSketchConflict, + crate::tf!("⚠ {parametric_conflicts} conflicting").into_owned(), + Message::ResolveOneParametricConflict, ), - t!("One or more constraints in this sketch conflict or are redundant\nClick to remove one and re-solve"), + t!("One or more parametric constraints conflict or are redundant\nClick to remove one and re-solve"), ) .into(), ); @@ -315,7 +311,11 @@ impl StatusBar { if vis(StatusPill::Lwt) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), + toggle_pill( + crate::ui::icons::ST_LWT, + lineweight_display, + Message::ToggleLineweightDisplay, + ), t!("Show Lineweight\nLWDISPLAY"), ) .into(), @@ -462,10 +462,7 @@ impl StatusBar { t!("Isolate Objects\nClick for Isolate / Hide / End"), tooltip_hidden, ), - crate::ui::popup::isolate_popup::menu_entries( - has_selection, - isolation_active, - ), + crate::ui::popup::isolate_popup::menu_entries(has_selection, isolation_active), 160.0, ) .into(), @@ -474,7 +471,11 @@ impl StatusBar { if vis(StatusPill::QuickProps) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), + toggle_pill( + crate::ui::icons::ST_QUICKPROPS, + quick_properties, + Message::ToggleQuickProperties, + ), t!("Quick Properties\nFloating panel on selection"), ) .into(), @@ -504,7 +505,11 @@ impl StatusBar { if vis(StatusPill::SelCycle) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), + toggle_pill( + crate::ui::icons::ST_SELCYCLE, + selection_cycling, + Message::ToggleSelectionCycling, + ), t!("Selection Cycling\nRepeat-click to step through overlapping objects"), ) .into(), @@ -522,7 +527,11 @@ impl StatusBar { if vis(StatusPill::CleanScreen) { pills.push( tip( - toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), + toggle_pill( + crate::ui::icons::ST_CLEANSCREEN, + clean_screen, + Message::ToggleCleanScreen, + ), t!("Clean Screen\nHide ribbon and panels"), ) .into(), @@ -607,13 +616,13 @@ impl StatusBar { .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() } }) .width(Length::Fill) @@ -634,7 +643,12 @@ impl StatusBar { /// drawing set to architectural units read its coordinates in decimals, and one /// asking for two places got four. `format_length` is the same helper the /// properties panel formats through, so both now say a length the same way. -fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, picking: bool) -> String { +fn format_coords( + cursor: glam::DVec3, + last: Option, + mode: i16, + picking: bool, +) -> String { use crate::entities::common::format_length as len; let abs = |p: glam::DVec3| format!("{}, {}, {}", len(p.x), len(p.y), len(p.z)); match mode { @@ -666,11 +680,14 @@ fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, pick // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { - button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 16.0)) - .on_press(Message::StatusMenuTooltipHidden(true)) - .style(button::subtle) - .padding([4, 8]) - .into() + button(crate::ui::icons::themed_secondary( + crate::ui::icons::MENU, + 16.0, + )) + .on_press(Message::StatusMenuTooltipHidden(true)) + .style(button::subtle) + .padding([4, 8]) + .into() } // ── Tooltip helper ──────────────────────────────────────────────────────── @@ -689,11 +706,7 @@ fn menu_tip<'a>( label: std::borrow::Cow<'static, str>, hidden: bool, ) -> Element<'a, Message> { - let content = if hidden { - content - } else { - tip(content, label) - }; + let content = if hidden { content } else { tip(content, label) }; mouse_area(content) .on_exit(Message::StatusMenuTooltipHidden(false)) @@ -763,21 +776,21 @@ fn split_pill<'a>( .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(if active { - palette.primary.weak.color - } else { - palette.background.weakest.color - })), - border: Border { - color: if active { - palette.primary.base.color + background: Some(Background::Color(if active { + palette.primary.weak.color } else { - palette.background.neutral.color + palette.background.weakest.color + })), + border: Border { + color: if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + ..Default::default() } }) .padding([4, 6]) @@ -880,8 +893,8 @@ fn osnap_btn<'a>( }; let main = tip( mouse_area(snap_icon) - .on_press(Message::ToggleSnapEnabled) - .into(), + .on_press(Message::ToggleSnapEnabled) + .into(), t!("Object Snap: toggle on/off\nF3"), ); @@ -926,14 +939,10 @@ fn layout_tab_context_menu(name: String) -> Element<'static, Message> { .on_press(Message::LayoutDelete(name)) .interaction(iced::mouse::Interaction::Pointer); - container( - column![rename, delete] - .spacing(0) - .width(160), - ) - .style(container::bordered_box) - .padding([4, 0]) - .into() + container(column![rename, delete].spacing(0).width(160)) + .style(container::bordered_box) + .padding([4, 0]) + .into() } /// A layout tab button. @@ -1020,12 +1029,7 @@ fn space_tab<'a>( let report_key = format!("{report_key_prefix}:{label}"); let tab = mouse_area(display).on_press(switch_msg); let tab: Element<'a, Message> = if has_context_menu { - crate::ui::wrap_bar::ReorderTab::layout( - label.clone(), - reorderable_layouts, - tab, - ) - .into() + crate::ui::wrap_bar::ReorderTab::layout(label.clone(), reorderable_layouts, tab).into() } else { tab.into() }; @@ -1035,11 +1039,7 @@ fn space_tab<'a>( } else { tab }; - crate::ui::wrap_bar::PosReport::owned( - report_key, - tab, - ) - .into() + crate::ui::wrap_bar::PosReport::owned(report_key, tab).into() } } @@ -1051,7 +1051,7 @@ fn space_tab<'a>( fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> { let is_model_tab = current_layout == "Model"; - // Labels and styling follow AutoCAD convention: + // Labels and styling follow the conventional drafting layout: // PAPER = currently in paper-space editing // MODEL = currently in model-space editing (either the Model tab or MSPACE) let (label, active, on_press) = if is_model_tab { @@ -1089,9 +1089,9 @@ fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Mes fn status_pill(label: impl Into) -> Element<'static, Message> { container(text(label.into()).size(12)) - .style(container::bordered_box) - .padding([4, 8]) - .into() + .style(container::bordered_box) + .padding([4, 8]) + .into() } /// A success-colored status pill used when no degrees of freedom remain. @@ -1123,7 +1123,11 @@ fn success_pill(label: impl Into) -> Element<'static, Message> { container::Style { background: Some(Background::Color(palette.success.weak.color)), text_color: Some(palette.success.weak.text), - border: Border { color: palette.success.base.color, width: 1.0, radius: 4.0.into() }, + border: Border { + color: palette.success.base.color, + width: 1.0, + radius: 4.0.into(), + }, ..Default::default() } }) @@ -1141,10 +1145,10 @@ fn popup_pill(label: impl Into) -> Element<'static, Message> { fn action_pill(label: impl Into, msg: Message) -> Element<'static, Message> { let label = label.into(); button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding([4, 7]) - .into() + .on_press(msg) + .style(button::subtle) + .padding([4, 7]) + .into() } // ── Scale display ───────────────────────────────────────────────────────── @@ -1196,13 +1200,10 @@ fn active_scale_label( .iter() .find(|(_, anno_scale, vp_scale)| { if is_model { - (annotation_scale - *anno_scale).abs() - < 0.001 * annotation_scale.max(0.001) + (annotation_scale - *anno_scale).abs() < 0.001 * annotation_scale.max(0.001) } else { viewport_scale - .map(|current| { - (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001) - }) + .map(|current| (current - *vp_scale).abs() < 0.001 * vp_scale.max(0.001)) .unwrap_or(false) } }) diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index 35628ae0a..88afb3bf8 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -9,7 +9,7 @@ //! Unlike the alias editor, each row also shows a live-computed resolved //! value or error (`preview`, below) — built fresh from the whole buffer on //! every render, not cached: the same "cheap enough to recompute from -//! scratch" philosophy `ParameterTable`/`sketch_solve.rs` already use +//! scratch" philosophy `ParameterTable`/`parametric_solve.rs` already use //! throughout this project, and it means the preview reflects circular/ //! undefined-reference problems across rows immediately, before Apply. @@ -393,7 +393,9 @@ mod tests { #[test] fn usage_lines_names_the_constraint_kind_and_its_entities() { - use crate::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; + use crate::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, + }; let mut scene = Scene::new(); let line = scene.add_entity(acadrust::EntityType::Line( acadrust::entities::Line::from_points( @@ -402,10 +404,10 @@ mod tests { ), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(crate::scene::named_parameters::DrivingValue::Named( "gap".to_string(), )), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 44e0906a9..3c7f168c3 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -145,7 +145,6 @@ impl fmt::Display for Labelled { pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, - write_dwg_native_constraints: bool, show_constraint_values: bool, ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], @@ -352,22 +351,6 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), Space::new().height(14), - row![ - iced::widget::checkbox(write_dwg_native_constraints) - .on_toggle(Message::WriteDwgNativeConstraintsChanged) - .size(15), - text(crate::t!("Write native constraint objects on save")).size(12), - ] - .spacing(8) - .align_y(iced::Center), - Space::new().height(6), - text(crate::t!( - "Saves sketch constraints as native drawing objects alongside this app's own format. \ - Off by default because it adds file size. Existing native objects stay synchronized." - )) - .size(11) - .width(sizing.width), - Space::new().height(14), row![ iced::widget::checkbox(show_constraint_values) .on_toggle(Message::ShowConstraintValuesChanged) diff --git a/tests/sketch_constraints_solve.rs b/tests/parametric_constraints_solve.rs similarity index 85% rename from tests/sketch_constraints_solve.rs rename to tests/parametric_constraints_solve.rs index ebb379c3d..ed71458a8 100644 --- a/tests/sketch_constraints_solve.rs +++ b/tests/parametric_constraints_solve.rs @@ -1,7 +1,9 @@ use acadrust::entities::EntityType; use acadrust::types::{Handle, Vector3}; use OpenCADStudio::scene::named_parameters::DrivingValue; -use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; +use OpenCADStudio::scene::parametric_constraints::{ + ConstraintKind, ParametricRef, ParametricScope, +}; use OpenCADStudio::scene::{ChangeKind, Scene}; fn add_line(scene: &mut Scene, x1: f64, y1: f64, x2: f64, y2: f64) -> Handle { @@ -62,13 +64,6 @@ fn arc_geom(scene: &Scene, handle: Handle) -> (Vector3, f64) { } } -fn arc_angles(scene: &Scene, handle: Handle) -> (f64, f64, f64) { - match scene.document.get_entity(handle).expect("entity exists") { - EntityType::Arc(a) => (a.radius, a.start_angle, a.end_angle), - other => panic!("expected an Arc, got {other:?}"), - } -} - fn add_ellipse(scene: &mut Scene, cx: f64, cy: f64, major_axis: (f64, f64), ratio: f64) -> Handle { scene.add_entity(EntityType::Ellipse( acadrust::entities::Ellipse::from_center_axes( @@ -92,10 +87,10 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); @@ -118,7 +113,7 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { // constraint-referenced params, so an untouched one was invisible to // `diagnose` entirely before this was fixed). let dof = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .and_then(|s| s.dof); assert_eq!( dof, @@ -138,10 +133,10 @@ fn horizontal_constraint_levels_a_polyline_segment() { ]), )); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 1)], + vec![ParametricRef::segment(polyline, 1)], None, ); @@ -163,10 +158,10 @@ fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() { let mut scene = Scene::new(); let polyline = scene.add_entity(EntityType::LwPolyline(source)); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::segment(polyline, 0)], + vec![ParametricRef::segment(polyline, 0)], None, ); @@ -185,10 +180,10 @@ fn horizontal_constraint_aligns_two_selected_points() { let a = add_line(&mut scene, 0.0, 1.0, 4.0, 3.0); let b = add_line(&mut scene, 8.0, 7.0, 12.0, 9.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::point(a, 0), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], None, ); @@ -205,10 +200,10 @@ fn parallel_constraint_rotates_the_other_line_when_one_moves() { let b = add_line(&mut scene, 0.0, 5.0, 10.0, 5.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); @@ -234,10 +229,10 @@ fn distance_constraint_holds_the_target_length_after_an_unrelated_endpoint_edit( let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(20.0)), ); @@ -265,10 +260,10 @@ fn distance_constraint_resolves_a_named_parameter_reference() { .unwrap(); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Named("target_len".to_string())), ); @@ -307,10 +302,10 @@ fn distance_constraint_with_an_undefined_named_reference_is_skipped_not_panicked let line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Named("does_not_exist".to_string())), ); @@ -331,10 +326,10 @@ fn tangent_constraint_between_a_line_and_a_circle_solves_to_touching() { let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(circle), SketchRef::whole(line)], + vec![ParametricRef::whole(circle), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -355,10 +350,10 @@ fn tangent_constraint_between_two_circles_solves_to_external_tangency() { let b = add_circle(&mut scene, 20.0, 0.0, 2.0); // far apart: distance 20, r1+r2 = 5 scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); @@ -380,10 +375,10 @@ fn coincident_constraint_pulls_the_second_point_onto_the_first_when_it_moves() { let b = add_line(&mut scene, 5.0, 0.0, 5.0, 5.0); // b.start coincident with a.end scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(a, 1), SketchRef::point(b, 0)], + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], None, ); @@ -406,10 +401,10 @@ fn unrelated_entity_edit_does_not_touch_constrained_geometry() { let unrelated = add_line(&mut scene, 100.0, 100.0, 200.0, 100.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], + vec![ParametricRef::whole(line)], None, ); @@ -434,20 +429,24 @@ fn erasing_a_constrained_entity_drops_its_constraints_instead_of_dangling() { let c = add_line(&mut scene, 20.0, 20.0, 30.0, 20.0); // unrelated, own constraint let parallel_id = scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); let horizontal_id = scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add(ConstraintKind::Horizontal, vec![SketchRef::whole(c)], None); + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(c)], + None, + ); scene.erase_entities(&[a]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .expect("scope still exists"); assert!( set.get(parallel_id).is_none(), @@ -475,16 +474,16 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let b = add_line(&mut scene, 0.0, 5.0, 10.0, 3.0); // deliberately not parallel yet scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); // Solve once so the source pair is actually parallel before copying. scene.bump_entities(&[(a, ChangeKind::Modified), (b, ChangeKind::Modified)]); let constraints_before = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(); @@ -496,7 +495,7 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { let (new_a, new_b) = (new_handles[0], new_handles[1]); let constraints_after = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap() .constraints .len(); @@ -534,25 +533,33 @@ fn copying_two_constrained_entities_carries_their_constraint_along() { // The "one edit that ripples through a constraint still records as one undo // step" case needs `Scene::record_undo_before`, which is `pub(crate)` (an // integration test crate can't reach it) — covered instead as an internal -// unit test in `src/scene/sketch_solve.rs`. +// unit test in `src/scene/parametric_solve.rs`. #[test] fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { // `solve_scope` resolves the redundant row back to the // `ConstraintId` a `ConflictResolverPanel` would name. use cadkernel_constraints::diagnosis::RedundancyKind; - use OpenCADStudio::scene::sketch_constraints::ConstraintKind; + use OpenCADStudio::scene::parametric_constraints::ConstraintKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 3.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let first = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); - let second = set.add(ConstraintKind::Horizontal, vec![SketchRef::whole(a)], None); // exact duplicate + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + let first = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); + let second = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); // exact duplicate scene.bump_entities(&[(a, ChangeKind::Modified)]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!( set.conflicts.len(), @@ -570,26 +577,26 @@ fn a_duplicated_horizontal_constraint_is_reported_as_redundant() { #[test] fn two_conflicting_distance_targets_are_reported_as_conflicting() { use cadkernel_constraints::diagnosis::RedundancyKind; - use OpenCADStudio::scene::sketch_constraints::ConstraintKind; + use OpenCADStudio::scene::parametric_constraints::ConstraintKind; let mut scene = Scene::new(); let a = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); let first = set.add( ConstraintKind::Distance, - vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)], Some(DrivingValue::Literal(20.0)), ); let second = set.add( ConstraintKind::Distance, - vec![SketchRef::point(a, 0), SketchRef::point(a, 1)], + vec![ParametricRef::point(a, 0), ParametricRef::point(a, 1)], Some(DrivingValue::Literal(50.0)), ); scene.bump_entities(&[(a, ChangeKind::Modified)]); let set = scene - .sketch_constraint_set(SketchScope::ModelSpace) + .parametric_constraint_set(ParametricScope::ModelSpace) .unwrap(); assert_eq!(set.conflicts.len(), 1); let (flagged_id, kind) = set.conflicts[0]; @@ -619,10 +626,10 @@ fn concentric_constraint_pulls_the_second_circles_center_onto_the_firsts() { let b = add_circle(&mut scene, 5.0, 5.0, 1.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(a), SketchRef::center(b)], + vec![ParametricRef::center(a), ParametricRef::center(b)], None, ); @@ -649,10 +656,10 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_a_circles_center() { let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(circle)], + vec![ParametricRef::point(line, 0), ParametricRef::center(circle)], None, ); @@ -674,10 +681,10 @@ fn colinear_constraint_pulls_the_second_line_onto_the_firsts_infinite_line() { let b = add_line(&mut scene, 3.0, 4.0, 7.0, 6.0); // off-axis, not on a's line scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Colinear, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); @@ -707,10 +714,10 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 21.0); // marker.start is the tracked point scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Midpoint, - vec![SketchRef::point(marker, 0), SketchRef::whole(base)], + vec![ParametricRef::point(marker, 0), ParametricRef::whole(base)], None, ); @@ -734,10 +741,10 @@ fn midpoint_constraint_supports_a_point_entity() { Vector3::new(20.0, 20.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Midpoint, - vec![SketchRef::point(point, 0), SketchRef::whole(base)], + vec![ParametricRef::point(point, 0), ParametricRef::whole(base)], None, ); @@ -759,10 +766,13 @@ fn coincident_constraint_supports_a_block_insertion_point() { Vector3::new(30.0, 20.0, 0.0), ))); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(line, 1), SketchRef::point(insert, 0)], + vec![ + ParametricRef::point(line, 1), + ParametricRef::point(insert, 0), + ], None, ); @@ -781,17 +791,17 @@ fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let fixed_line = add_line(&mut scene, 0.0, 0.0, 10.0, 0.0); let moving_line = add_line(&mut scene, 10.0, 0.0, 10.0, 10.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); set.add( ConstraintKind::Fixed, - vec![SketchRef::whole(fixed_line)], + vec![ParametricRef::whole(fixed_line)], None, ); set.add( ConstraintKind::Coincident, vec![ - SketchRef::point(fixed_line, 1), - SketchRef::point(moving_line, 0), + ParametricRef::point(fixed_line, 1), + ParametricRef::point(moving_line, 0), ], None, ); @@ -825,10 +835,13 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { let marker = add_line(&mut scene, 100.0, 100.0, 101.0, 101.0); // marker.start is the tracked point scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(circle)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(circle), + ], None, ); @@ -850,10 +863,13 @@ fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() { let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(ellipse)], + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(ellipse), + ], None, ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); @@ -879,10 +895,10 @@ fn tangent_constraint_solves_an_ellipse_and_line() { let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(ellipse), SketchRef::whole(line)], + vec![ParametricRef::whole(ellipse), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(line, ChangeKind::Modified)]); @@ -911,10 +927,10 @@ fn equal_constraint_matches_ellipse_major_axes() { let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Equal, - vec![SketchRef::whole(a), SketchRef::whole(b)], + vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); scene.bump_entities(&[(b, ChangeKind::Modified)]); @@ -938,14 +954,14 @@ fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let b = add_line(&mut scene, 20.0, 20.0, 25.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::EqualDistance, vec![ - SketchRef::point(b, 0), - SketchRef::point(b, 1), - SketchRef::point(a, 0), - SketchRef::point(a, 1), + ParametricRef::point(b, 0), + ParametricRef::point(b, 1), + ParametricRef::point(a, 0), + ParametricRef::point(a, 1), ], None, ); @@ -972,21 +988,25 @@ fn symmetric_constraint_mirrors_one_circles_center_across_the_axis_line() { // scale dividing by zero at the very first solve iteration). let b = add_circle(&mut scene, 8.0, 9.0, 1.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); // Pin the axis and `a` in place — otherwise they're just as free to move // as `b`'s center, and with only 2 equations (MidpointOnLine + // Perpendicular) against that many more unknowns the solver is free to // converge on any of infinitely many valid configurations, not // necessarily "only b moves, to a's exact mirror" (which is the one // deterministic outcome this test actually wants to check). - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(axis)], None); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(a)], None); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(axis)], + None, + ); + set.add(ConstraintKind::Fixed, vec![ParametricRef::whole(a)], None); set.add( ConstraintKind::Symmetric, vec![ - SketchRef::center(a), - SketchRef::center(b), - SketchRef::whole(axis), + ParametricRef::center(a), + ParametricRef::center(b), + ParametricRef::whole(axis), ], None, ); @@ -1019,10 +1039,10 @@ fn radius_constraint_on_an_arc_solves_to_the_target() { let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(DrivingValue::Literal(7.5)), ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1041,10 +1061,10 @@ fn tangent_constraint_between_a_line_and_an_arc_solves_to_touching() { let line = add_line(&mut scene, -10.0, 10.0, 10.0, 10.0); // clear of the arc's circle scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Tangent, - vec![SketchRef::whole(arc), SketchRef::whole(line)], + vec![ParametricRef::whole(arc), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -1064,10 +1084,10 @@ fn concentric_constraint_between_an_arc_and_a_circle_pulls_centers_together() { let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(arc), SketchRef::center(circle)], + vec![ParametricRef::center(arc), ParametricRef::center(circle)], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified), (circle, ChangeKind::Modified)]); @@ -1102,10 +1122,10 @@ fn coincident_constraint_pulls_a_lines_endpoint_onto_an_arcs_start_point() { let line = add_line(&mut scene, 20.0, 20.0, 30.0, 30.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)], + vec![ParametricRef::point(arc, 0), ParametricRef::point(line, 0)], None, ); @@ -1138,11 +1158,18 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); let anchor = add_line(&mut scene, 12.0, 7.0, 12.0, 7.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + vec![ + ParametricRef::point(arc, 0), + ParametricRef::point(anchor, 0), + ], None, ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1176,10 +1203,10 @@ fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::PointOnCurve, - vec![SketchRef::point(marker, 0), SketchRef::whole(arc)], + vec![ParametricRef::point(marker, 0), ParametricRef::whole(arc)], None, ); scene.bump_entities(&[(marker, ChangeKind::Modified)]); @@ -1202,16 +1229,23 @@ fn radius_and_endpoint_coincident_constraints_coexist_on_the_same_arc() { let arc = add_arc(&mut scene, 0.0, 0.0, 3.0, 0.0, std::f64::consts::FRAC_PI_2); let anchor = add_line(&mut scene, 9.0, -4.0, 9.0, -4.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(anchor)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(anchor)], + None, + ); set.add( ConstraintKind::Coincident, - vec![SketchRef::point(arc, 0), SketchRef::point(anchor, 0)], + vec![ + ParametricRef::point(arc, 0), + ParametricRef::point(anchor, 0), + ], None, ); set.add( ConstraintKind::Radius, - vec![SketchRef::whole(arc)], + vec![ParametricRef::whole(arc)], Some(DrivingValue::Literal(6.0)), ); scene.bump_entities(&[(arc, ChangeKind::Modified)]); @@ -1238,10 +1272,10 @@ fn diameter_constraint_drives_the_radius_to_half_the_target() { let circle = add_circle(&mut scene, 0.0, 0.0, 3.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Diameter, - vec![SketchRef::whole(circle)], + vec![ParametricRef::whole(circle)], Some(DrivingValue::Literal(16.0)), ); scene.bump_entities(&[(circle, ChangeKind::Modified)]); @@ -1261,10 +1295,10 @@ fn distance_x_constraint_pins_only_the_x_component() { let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::DistanceX, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(10.0)), ); scene.bump_entities(&[(line, ChangeKind::Modified)]); @@ -1289,10 +1323,10 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { let line = add_line(&mut scene, -10.0, 0.0, 10.0, 0.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Normal, - vec![SketchRef::whole(circle), SketchRef::whole(line)], + vec![ParametricRef::whole(circle), ParametricRef::whole(line)], None, ); scene.bump_entities(&[(circle, ChangeKind::Modified), (line, ChangeKind::Modified)]); @@ -1308,42 +1342,6 @@ fn normal_constraint_pulls_the_line_through_the_circles_center() { ); } -// Phase 4a of the same plan: `ArcLength` registers an arc's -// `start_angle`/`end_angle` separately from its center/radius (which still -// flow through the existing `EntityGeom::Circle` aliasing), so it can -// combine with a `Radius` constraint on the same arc in one solve. - -#[test] -fn arc_length_constraint_drives_the_swept_length_to_the_target() { - let mut scene = Scene::new(); - // radius 5, a 90° span (length ≈ 7.85) — deliberately not the target. - let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::FRAC_PI_2); - - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(5.0)), - ); - set.add( - ConstraintKind::ArcLength, - vec![SketchRef::whole(arc)], - Some(DrivingValue::Literal(10.0)), - ); - scene.bump_entities(&[(arc, ChangeKind::Modified)]); - - let (radius, start_angle, end_angle) = arc_angles(&scene, arc); - assert!( - (radius - 5.0).abs() < 1e-6, - "Radius constraint should still hold: got {radius}" - ); - let length = radius * (end_angle - start_angle); - assert!( - (length - 10.0).abs() < 1e-5, - "swept length should equal the ArcLength target: got {length}" - ); -} - // Phase 5 of the same plan: an `Ellipse` registers center/focus1/minor- // radius (converted from acadrust's center + major-axis-vector + ratio // parametrization). Only center-based whole-entity constraints are wired @@ -1358,10 +1356,13 @@ fn concentric_constraint_between_an_ellipse_and_a_circle_pulls_centers_together( let circle = add_circle(&mut scene, 10.0, -6.0, 2.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[ @@ -1384,10 +1385,13 @@ fn center_point_constraint_pulls_a_lines_endpoint_onto_an_ellipses_center() { let line = add_line(&mut scene, 10.0, 10.0, 20.0, 20.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + vec![ + ParametricRef::point(line, 0), + ParametricRef::center(ellipse), + ], None, ); @@ -1421,10 +1425,13 @@ fn a_tilted_ellipse_round_trips_through_the_solver_without_drifting() { let circle = add_circle(&mut scene, 2.0, -3.0, 1.0); // already concentric — no solving needed scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[(ellipse, ChangeKind::Modified)]); @@ -1463,10 +1470,13 @@ fn concentric_constraint_does_not_reshape_the_ellipse_when_its_center_actually_m let circle = add_circle(&mut scene, 10.0, 7.0, 1.5); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::Concentric, - vec![SketchRef::center(ellipse), SketchRef::center(circle)], + vec![ + ParametricRef::center(ellipse), + ParametricRef::center(circle), + ], None, ); scene.bump_entities(&[ @@ -1500,11 +1510,18 @@ fn fixed_constraint_holds_an_ellipse_in_place_despite_a_connected_edit() { let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (6.0, 0.0), 0.5); let line = add_line(&mut scene, 0.0, 0.0, 5.0, 5.0); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - set.add(ConstraintKind::Fixed, vec![SketchRef::whole(ellipse)], None); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Fixed, + vec![ParametricRef::whole(ellipse)], + None, + ); set.add( ConstraintKind::CenterPoint, - vec![SketchRef::point(line, 0), SketchRef::center(ellipse)], + vec![ + ParametricRef::point(line, 0), + ParametricRef::center(ellipse), + ], None, ); @@ -1534,10 +1551,10 @@ fn distance_y_constraint_pins_only_the_y_component() { let line = add_line(&mut scene, 0.0, 0.0, 6.0, 8.0); scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) + .parametric_constraint_set_mut(ParametricScope::ModelSpace) .add( ConstraintKind::DistanceY, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], Some(DrivingValue::Literal(-3.0)), ); scene.bump_entities(&[(line, ChangeKind::Modified)]); diff --git a/tests/sketch_constraints_xrecord_roundtrip.rs b/tests/sketch_constraints_xrecord_roundtrip.rs deleted file mode 100644 index 435218ac4..000000000 --- a/tests/sketch_constraints_xrecord_roundtrip.rs +++ /dev/null @@ -1,159 +0,0 @@ -// Proves a SketchConstraintSet serialized into an XRecord entry -// block record's extension dictionary, survives a real save/reload through -// both supported file formats byte-for-byte. - -use acadrust::entities::EntityType; -use acadrust::objects::{XRecordEntry, XRecordValue}; -use acadrust::types::{Handle, Vector3}; -use OpenCADStudio::scene::named_parameters::DrivingValue; -use OpenCADStudio::scene::sketch_constraints::{ - ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, -}; -use OpenCADStudio::scene::Scene; - -const RECORD_KEY: &str = "OCS_SKETCH_CONSTRAINTS_TEST"; - -fn sample_set(scope: SketchScope) -> SketchConstraintSet { - let mut set = SketchConstraintSet::new(scope); - set.add( - ConstraintKind::Coincident, - vec![ - SketchRef::point(Handle::new(101), 0), - SketchRef::point(Handle::new(102), 1), - ], - None, - ); - set.add( - ConstraintKind::Distance, - vec![SketchRef::whole(Handle::new(103))], - Some(DrivingValue::Literal(25.0)), - ); - set.add( - ConstraintKind::Angle, - vec![ - SketchRef::whole(Handle::new(104)), - SketchRef::whole(Handle::new(105)), - ], - Some(DrivingValue::Literal(45.0)), - ); - set.add( - ConstraintKind::Radius, - vec![SketchRef::whole(Handle::new(106))], - Some(DrivingValue::Literal(3.5)), - ); - set -} - -/// Writes `set` into a fresh XRecord Chunk under `owner`'s extension -/// dictionary, round-trips the whole document through `ext` (`"dxf"` or -/// anything else for DWG), and returns the `SketchConstraintSet` read back -/// out of the reloaded document. -fn roundtrip_through( - scene: &Scene, - owner: Handle, - set: &SketchConstraintSet, - ext: &str, -) -> SketchConstraintSet { - let mut doc = scene.document.clone(); - let bytes = bincode::serialize(set).expect("serialize SketchConstraintSet"); - - doc.ensure_xrecord(owner, RECORD_KEY); - let record = doc - .xrecord_mut(owner, RECORD_KEY) - .expect("xrecord_mut after ensure_xrecord"); - record - .entries - .push(XRecordEntry::new(310, XRecordValue::Chunk(bytes))); - - let saved = OpenCADStudio::io::save_to_bytes(&doc, ext, doc.version) - .unwrap_or_else(|e| panic!("save to {ext} bytes: {e}")); - let reloaded = OpenCADStudio::io::load_bytes(&format!("roundtrip.{ext}"), saved) - .unwrap_or_else(|e| panic!("reload {ext} bytes: {e}")); - - let record = reloaded - .xrecord(owner, RECORD_KEY) - .unwrap_or_else(|| panic!("XRecord {RECORD_KEY:?} missing after {ext} round-trip")); - let chunk = record - .entries - .iter() - .find_map(|entry| match &entry.value { - XRecordValue::Chunk(bytes) => Some(bytes.clone()), - _ => None, - }) - .unwrap_or_else(|| panic!("no Chunk entry survived the {ext} round-trip")); - - bincode::deserialize::(&chunk) - .unwrap_or_else(|e| panic!("deserialize round-tripped {ext} blob: {e}")) -} - -#[test] -fn sketch_constraint_set_survives_a_dxf_roundtrip() { - let scene = Scene::new(); - let owner = scene.document.header.model_space_block_handle; - assert!( - !owner.is_null(), - "a fresh document must already have a model-space block handle" - ); - let original = sample_set(SketchScope::ModelSpace); - - let restored = roundtrip_through(&scene, owner, &original, "dxf"); - - assert_eq!(restored.scope, original.scope); - assert_eq!(restored.constraints.len(), original.constraints.len()); - for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) { - assert_eq!(r.id, o.id); - assert_eq!(r.kind, o.kind); - assert_eq!(r.refs, o.refs); - assert_eq!(r.driving_param, o.driving_param); - assert_eq!(r.enabled, o.enabled); - } -} - -#[test] -fn sketch_constraint_set_survives_a_dwg_roundtrip() { - let scene = Scene::new(); - let owner = scene.document.header.model_space_block_handle; - let original = sample_set(SketchScope::ModelSpace); - - let restored = roundtrip_through(&scene, owner, &original, "dwg"); - - assert_eq!(restored.scope, original.scope); - assert_eq!(restored.constraints.len(), original.constraints.len()); - for (r, o) in restored.constraints.iter().zip(original.constraints.iter()) { - assert_eq!(r.id, o.id); - assert_eq!(r.kind, o.kind); - assert_eq!(r.refs, o.refs); - assert_eq!(r.driving_param, o.driving_param); - } -} - -/// A block-record-scoped set (not model space) — confirms the XRecord -/// extension-dictionary mechanism works for an arbitrary owner handle, not -/// just the one every document already has. -#[test] -fn sketch_constraint_set_survives_roundtrip_when_owned_by_a_block_record() { - let mut scene = Scene::new(); - // A real block definition via the app's own `create_block_from_entities` - // (needs at least one source entity) — a bare, entity-less BlockRecord - // table entry turned out not to survive a DXF round-trip at all (the - // writer prunes it as unreferenced), so this exercises the actual shape - // `SketchScope::Block` will see in practice. - let line_handle = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 0.0, 0.0), - ))); - let br_handle = scene - .create_block_from_entities( - &[line_handle], - "OCS_TEST_BLOCK", - &acadrust::types::Transform::identity(), - &acadrust::types::Transform::identity(), - ) - .expect("create a block definition to own the constraint set"); - - let original = sample_set(SketchScope::Block(br_handle)); - let restored = roundtrip_through(&scene, br_handle, &original, "dxf"); - - assert_eq!(restored.scope, SketchScope::Block(br_handle)); - assert_eq!(restored.constraints.len(), original.constraints.len()); -}