diff --git a/.cargo/config.toml b/.cargo/config.toml index de96c3e4a..876a223e7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -22,6 +22,7 @@ rustflags = [ [target.x86_64-pc-windows-gnu] rustflags = [ "-Clink-arg=-Wl,--stack,16777216", + "-Clink-arg=-Wl,--exclude-all-symbols", ] [target.x86_64-pc-windows-msvc] diff --git a/Cargo.lock b/Cargo.lock index 49635a72c..76a3880ef 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=4d7b359b0571f9b6e0f85a62a21c67508722b83e#4d7b359b0571f9b6e0f85a62a21c67508722b83e" +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=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" 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=50f020377c01fcebb5f31844b9900f046b05192f#50f020377c01fcebb5f31844b9900f046b05192f" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" dependencies = [ "nalgebra", ] @@ -4601,6 +4601,7 @@ dependencies = [ "base64", "flate2", "getrandom 0.3.4", + "image", "lopdf", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..ed9e2120a 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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "50f020377c01fcebb5f31844b9900f046b05192f" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -60,7 +60,7 @@ fluent-syntax = "0.12" naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } @@ -85,7 +85,7 @@ meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" -printpdf = { version = "0.9.1", default-features = false } +printpdf = { version = "0.9.1", default-features = false, features = ["images"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/assets/icons/align_bottom.svg b/assets/icons/align_bottom.svg new file mode 100644 index 000000000..ca7fa2bea --- /dev/null +++ b/assets/icons/align_bottom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_hcenter.svg b/assets/icons/align_hcenter.svg new file mode 100644 index 000000000..7ea838f62 --- /dev/null +++ b/assets/icons/align_hcenter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_left.svg b/assets/icons/align_left.svg new file mode 100644 index 000000000..aedbf0ec9 --- /dev/null +++ b/assets/icons/align_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_right.svg b/assets/icons/align_right.svg new file mode 100644 index 000000000..35f934aa0 --- /dev/null +++ b/assets/icons/align_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_top.svg b/assets/icons/align_top.svg new file mode 100644 index 000000000..99efe0452 --- /dev/null +++ b/assets/icons/align_top.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/align_vcenter.svg b/assets/icons/align_vcenter.svg new file mode 100644 index 000000000..4740c61fc --- /dev/null +++ b/assets/icons/align_vcenter.svg @@ -0,0 +1,4 @@ + + + + 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 aea33085c..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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", 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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", 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 809c2090a..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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", 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 290103605..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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e", 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 f46779ba5..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 = "4d7b359b0571f9b6e0f85a62a21c67508722b83e" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" } diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index bbed9a20c..97ef76563 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -112,13 +112,23 @@ codesign --verify --strict --verbose=2 "$APP" echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" + +# Stage the app next to the install-location shortcut (#769). +STAGING="$DIST/dmg-staging" +rm -rf "$STAGING" +mkdir -p "$STAGING" +cp -R "$APP" "$STAGING/" +ln -s /Applications "$STAGING/Applications" + for i in 1 2 3 4 5; do - if hdiutil create -volname "Open CAD Studio" -srcfolder "$APP" -ov -format UDZO "$DMG"; then + if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDZO "$DMG"; then break fi + rm -f "$DMG" echo "hdiutil failed (attempt $i), retrying..." >&2 sleep 3 done +rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then diff --git a/src/app/automation.rs b/src/app/automation.rs index a862a29cf..8012361d7 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; @@ -317,8 +317,10 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; - self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.load_named_parameters_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,20 @@ 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") + ); + } + + #[test] + fn test_open_color_dropdown() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let wid = app.main_window.unwrap_or_else(iced::window::Id::unique); + let _ = app.view(wid); + let _ = app.update(Message::ToggleRibbonDropdown("PROP_COLOR".to_string())); + let _ = app.view(wid); } } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 35259107d..d8f355ed9 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) } @@ -216,9 +211,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); @@ -271,8 +264,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; } @@ -285,8 +280,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(); @@ -310,7 +306,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 @@ -318,15 +315,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()) @@ -334,9 +340,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); } @@ -415,7 +427,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() { @@ -426,7 +440,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; @@ -453,7 +469,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 = { @@ -620,11 +638,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(); @@ -799,8 +820,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, @@ -955,28 +975,64 @@ impl OpenCADStudio { TableCellEditStart::Started } - /// Removes and re-solves the first conflicting constraint in the active scope. - pub(super) fn resolve_one_sketch_conflict(&mut self) { + fn remove_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + label: &'static str, + ) -> bool { 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 false; + }; + let Some(constraint) = set.get(id) else { + return false; + }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); - let label = "Remove conflicting constraint"; - let constraints_before = set.clone(); 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); + if self.tabs[i].scene.selected_constraint == Some(id) { + self.tabs[i].scene.selected_constraint = None; + } + 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); } + self.refresh_properties(); + true + } + + /// Removes and re-solves the first conflicting constraint in the active scope. + pub(super) fn resolve_one_parametric_conflict(&mut self) { + let i = self.active_tab; + let scope = self.tabs[i].current_parametric_scope(); + let Some(id) = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .and_then(|set| set.conflicts.first().map(|(id, _)| *id)) + else { + return; + }; + self.remove_parametric_constraint(id, "Remove conflicting constraint"); + } + + /// Removes one user-selected constraint from the active standard graph scope. + pub(super) fn delete_parametric_constraint( + &mut self, + id: crate::scene::parametric_constraints::ConstraintId, + ) { + self.remove_parametric_constraint(id, "Delete constraint"); } /// Rebuilds the active tab's `Scene::named_parameters` from the @@ -987,7 +1043,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() { @@ -996,12 +1054,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(); @@ -1013,10 +1078,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(); @@ -1025,15 +1096,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()); } } @@ -1046,7 +1122,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| { @@ -1057,10 +1133,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); } @@ -1089,7 +1168,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(); }; @@ -1104,7 +1189,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(); } @@ -1115,13 +1201,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()) } @@ -1129,7 +1229,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(); } @@ -1137,7 +1238,7 @@ impl OpenCADStudio { if field == ParamField::Name { let affected: Vec = self.tabs[i] .scene - .sketch_constraints + .parametric_constraints .iter() .enumerate() .filter_map(|(index, set)| { @@ -1150,11 +1251,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(); } @@ -1177,7 +1284,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); @@ -1215,6 +1328,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); @@ -1243,25 +1357,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); } } @@ -1279,8 +1415,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(); } @@ -1300,7 +1435,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); @@ -1352,8 +1490,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(); @@ -1435,14 +1576,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()), } } _ => { @@ -1477,27 +1616,132 @@ 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() } + fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { + let supported_creation = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); + if supported_creation { + self.apply_inferred_constraints(i, new_handles); + } + } + + pub(in crate::app) fn apply_inferred_constraints( + &mut self, + i: usize, + changed_handles: &[acadrust::Handle], + ) { + if !self.constraint_infer || changed_handles.is_empty() { + return; + } + let scope = self.tabs[i].current_parametric_scope(); + let owner = scope.owner_handle(&self.tabs[i].scene.document); + let candidates = self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.handle == owner) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &candidates, + &self.auto_constrain_settings, + ); + let inferred: Vec<_> = inferred + .into_iter() + .filter(|(_, refs)| { + refs.iter() + .any(|reference| changed_handles.contains(&reference.entity)) + }) + .collect(); + if inferred.is_empty() { + return; + } + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let mut touched = Vec::new(); + for (kind, refs) in inferred { + touched.extend(refs.iter().map(|reference| reference.entity)); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + touched.sort_unstable_by_key(|handle| handle.value()); + touched.dedup(); + let changes: Vec<_> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + } + 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::OpenAutoConstrainSettings => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(super::ModalKind::AutoConstrainSettings); + } CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to // the attribute editor dialog and end the command (open_attribute @@ -1605,6 +1849,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1627,28 +1874,36 @@ 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)); } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -1661,28 +1916,36 @@ 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)); } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -1710,9 +1973,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) @@ -1743,36 +2004,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); } @@ -1805,9 +2057,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() { @@ -1829,8 +2079,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); @@ -1838,8 +2089,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); @@ -1857,9 +2109,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] @@ -1868,12 +2119,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; @@ -1881,10 +2129,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(); @@ -1905,9 +2152,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 { @@ -1921,17 +2166,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); } @@ -1950,12 +2195,20 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; return Task::none(); } + if matches!(&transform, crate::command::EntityTransform::Translate(_)) { + let scope = self.tabs[i].current_parametric_scope(); + handles = self.tabs[i] + .scene + .parametric_connected_handles(scope, &handles); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + } let label = self.history_label_from_active_cmd(i, "MOVE"); - // A move/rotate/scale/mirror mutates only the selected entities - // (and their baked dimension sub-entities) through - // transform_entities — always delta-safe. + // A translation carries its complete connected constraint + // component so each member keeps its own dimensions. Other + // transforms retain their selected-object behavior. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); + self.apply_inferred_constraints(i, &handles); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -1994,10 +2247,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() @@ -2076,6 +2327,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2095,16 +2349,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(_)) ); // A dimension placed on the sheet but measuring model geometry // through a viewport carries the compensation as a negative @@ -2168,9 +2416,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); @@ -2209,9 +2455,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) })); @@ -2231,11 +2478,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 { @@ -2244,11 +2487,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(); @@ -2291,9 +2530,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); @@ -2342,9 +2579,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); @@ -2367,7 +2607,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); } @@ -2388,9 +2631,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(); @@ -2404,9 +2646,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(); @@ -2422,15 +2663,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 { @@ -2489,9 +2725,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; @@ -2521,14 +2755,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; @@ -2561,15 +2792,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(); @@ -2619,11 +2847,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); } @@ -2661,10 +2888,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); } @@ -2686,11 +2912,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); } @@ -2749,7 +2974,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); @@ -2775,7 +3000,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); } @@ -2819,36 +3044,50 @@ 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); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); + 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)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -2858,48 +3097,197 @@ 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 { + first, + second, + multiple, + 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_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) - }); - 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, - refs: vec![ref_a, ref_b], - driving_param: None, - label, - }); + let resolve = |pick: crate::command::CoincidentPick, + exclude: Option| { + if pick.whole_curve { + pick.handle.and_then(|handle| { + crate::scene::parametric_constraints::parametric_curve_ref_for_pick( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + }) + } else if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + exclude, + ) } + }; + let resolved_first = resolve(first, None); + let resolved_second = resolved_first + .and_then(|reference| resolve(second, Some(reference.entity))); + let (Some(first_ref), Some(second_ref)) = (resolved_first, resolved_second) else { + self.command_line.push_error( + "Coincident: select a supported endpoint, center, midpoint, vertex, or curve.", + ); + return Task::none(); + }; + let (kind, refs) = match (first.whole_curve, second.whole_curve) { + (false, false) if first_ref != second_ref => ( + crate::scene::parametric_constraints::ConstraintKind::Coincident, + vec![first_ref, second_ref], + ), + (true, false) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![second_ref, first_ref], + ), + (false, true) => ( + crate::scene::parametric_constraints::ConstraintKind::PointOnCurve, + vec![first_ref, second_ref], + ), _ => { - self.command_line.push_error( - "Coincident: pick didn't land on a point (endpoint, center, or vertex) — enable an Endpoint/Center object snap and try again.", - ); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; + self.command_line + .push_error("Coincident: select one point and one curve, or two points."); + return Task::none(); } + }; + if let Err(error) = self.tabs[i] + .scene + .validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(error); + return Task::none(); + } + let touched: Vec<_> = refs.iter().map(|reference| reference.entity).collect(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + let changes: Vec<_> = touched + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first_ref], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].snap_result = None; + if !multiple { + self.tabs[i].active_cmd = None; + } + self.command_line.push_output("Coincident constraint applied."); + if multiple { + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|cmd| cmd.prompt()) { + self.command_line.push_info(&prompt); + } + self.command_line.set_step_options( + self.tabs[i] + .active_cmd + .as_ref() + .map(|cmd| cmd.options()) + .unwrap_or_default(), + ); + } + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); } } - CmdResult::AddPointOnEntityConstraint { point, target, kind, label } => { - let scope = self.tabs[i].current_sketch_scope(); + CmdResult::AddAutoCoincidentConstraints { handles } => { + use crate::scene::parametric_constraints::ConstraintKind; + let scope = self.tabs[i].current_parametric_scope(); + let inferred = self.tabs[i] + .scene + .inferred_coincident_constraints(scope, &handles); + let count = inferred.len(); + if count > 0 { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Coincident auto constrain", handles.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + for refs in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Coincident, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(format!("{} coincident constraint(s) applied.", count).as_str()); + } + 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, @@ -2916,20 +3304,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, @@ -2941,19 +3333,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; @@ -3089,12 +3489,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()); @@ -3211,11 +3622,15 @@ impl OpenCADStudio { // snapshot — the create already pushed one, so the whole object // reverts as a unit. if let Some(old) = self.tabs[i].scene.document.get_entity_mut(handle) { - let old_handle = old.as_entity().handle(); - let layer = old.as_entity().layer().to_string(); + // The initial live commit assigns the handle, owning block, + // current layer and common display properties. Geometry + // refreshes must retain all of that identity; replacing only + // the handle and layer reset owner_handle to NULL and made + // the completed entity unavailable to scoped operations such + // as parametric constraints. + let common = old.common().clone(); let mut new = entity; - new.as_entity_mut().set_handle(old_handle); - new.as_entity_mut().set_layer(layer); + *new.common_mut() = common; *old = new; self.tabs[i] .scene @@ -3288,11 +3703,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 { @@ -3400,7 +3818,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, }) }); @@ -3418,15 +3838,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"); } } @@ -3463,7 +3881,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 } => { @@ -3600,15 +4019,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(); @@ -3619,7 +4040,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); @@ -3751,14 +4173,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(); @@ -3783,7 +4207,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; @@ -3889,7 +4314,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); @@ -3917,7 +4343,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 => { @@ -3950,8 +4377,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; @@ -3993,24 +4421,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 @@ -4040,9 +4497,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 => { @@ -4068,12 +4526,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; @@ -4085,7 +4545,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() { @@ -4093,7 +4556,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 { @@ -4127,8 +4592,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. @@ -4145,9 +4611,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); } @@ -4176,10 +4642,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)), @@ -4191,9 +4654,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 @@ -4276,11 +4738,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)); @@ -4304,15 +4762,13 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); let mut count = 0usize; let mut changed_handles = Vec::new(); + let mut driven_refs = Vec::new(); // Helper: is DXF point (x, y) inside the world-space window? // 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 }) }; @@ -4325,6 +4781,54 @@ impl OpenCADStudio { let mut stretched_dims: Vec = Vec::new(); for handle in &handles { let before = self.tabs[i].scene.document.get_entity_arc(*handle); + if let Some(entity) = before.as_deref() { + use crate::scene::parametric_constraints::ParametricRef; + match entity { + acadrust::EntityType::Line(line) => { + if in_win(line.start.x, line.start.y) { + driven_refs.push(ParametricRef::point(*handle, 0)); + } + if in_win(line.end.x, line.end.y) { + driven_refs.push(ParametricRef::point(*handle, 1)); + } + } + acadrust::EntityType::LwPolyline(polyline) => { + if let Some(world) = + crate::entities::curve::lwpolyline_world_xy(polyline) + { + for (index, vertex) in world.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + } + acadrust::EntityType::Polyline2D(polyline) => { + for (index, vertex) in polyline.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + acadrust::EntityType::Circle(circle) + if in_win(circle.center.x, circle.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + acadrust::EntityType::Arc(arc) + if in_win(arc.center.x, arc.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + _ => {} + } + } let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { continue; }; @@ -4347,8 +4851,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; }; @@ -4431,12 +4934,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) => { @@ -4553,7 +5051,12 @@ impl OpenCADStudio { .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + ); + self.apply_inferred_constraints(i, &changed_handles); } self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; @@ -4585,9 +5088,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] @@ -4609,7 +5110,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; }; @@ -4639,20 +5141,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, @@ -4667,9 +5165,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(|| { @@ -4701,28 +5197,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; @@ -4780,9 +5276,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 { @@ -4801,13 +5296,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 @@ -4864,10 +5358,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); } @@ -4890,9 +5393,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(); @@ -4941,27 +5442,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; @@ -5038,7 +5533,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 @@ -5051,20 +5549,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; @@ -5073,7 +5572,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), ) }; @@ -5103,7 +5603,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); @@ -5116,39 +5618,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( @@ -5159,15 +5691,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 @@ -5194,10 +5734,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); @@ -5219,14 +5763,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(); @@ -5304,39 +5842,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, @@ -5354,20 +5916,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 { @@ -5381,7 +5966,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(); } } @@ -5393,7 +5979,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, @@ -5403,7 +5992,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, @@ -5457,10 +6050,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] @@ -5473,12 +6065,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(); @@ -5493,33 +6098,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) @@ -5540,30 +6191,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 => { @@ -5589,7 +6249,7 @@ impl OpenCADStudio { } HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack - | HatchEditOperation::DrawOrderBoundary {..} + | HatchEditOperation::DrawOrderBoundary { .. } | HatchEditOperation::BeginAssociate => unreachable!(), } self.tabs[i].dirty = true; @@ -5689,10 +6349,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()); @@ -5721,8 +6388,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 { @@ -5818,7 +6486,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 @@ -5863,11 +6532,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; @@ -5992,7 +6657,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(); @@ -6042,7 +6707,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 @@ -6342,16 +7009,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 @@ -6373,7 +7034,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 { @@ -6395,7 +7059,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, @@ -6475,9 +7142,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")); @@ -6535,8 +7200,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, }; } @@ -6604,11 +7272,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 => {} } @@ -6645,16 +7316,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, @@ -6662,7 +7345,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, ) } @@ -6679,11 +7366,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 { @@ -6721,8 +7405,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 { @@ -6752,9 +7442,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]; @@ -6791,9 +7479,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) @@ -6831,8 +7517,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; }; @@ -7321,44 +8006,104 @@ 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()); } + + #[test] + fn live_geometry_updates_keep_document_identity_and_display_properties() { + 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 expected = { + let entity = app.tabs[tab].scene.document.get_entity_mut(handle).unwrap(); + entity.common_mut().layer = "LIVE".to_string(); + entity.common_mut().invisible = true; + entity.common_mut().linetype_scale = 2.5; + entity.common().clone() + }; + + let _ = app.apply_cmd_result(CmdResult::UpdateLiveEntity { + handle, + entity: acadrust::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + )), + finish: false, + }); + + let updated = app.tabs[tab].scene.document.get_entity(handle).unwrap(); + assert_eq!(updated.common().handle, expected.handle); + assert_eq!(updated.common().owner_handle, expected.owner_handle); + assert_eq!(updated.common().layer, expected.layer); + assert_eq!(updated.common().invisible, expected.invisible); + assert_eq!(updated.common().linetype_scale, expected.linetype_scale); + assert!(matches!(updated, acadrust::EntityType::Line(line) if line.end.x == 4.0)); + } } #[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:?}"), } } @@ -7372,15 +8117,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 ); @@ -7388,7 +8141,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, @@ -7400,15 +8153,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(); @@ -7419,13 +8180,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, @@ -7435,36 +8198,99 @@ 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" + ); + } + + #[test] + fn deleting_one_parametric_constraint_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::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(line)], + driving_param: None, + label: "Horizontal constraint", + }); + let id = app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0] + .id; + app.tabs[app.active_tab].scene.selected_constraint = Some(id); + + app.delete_parametric_constraint(id); + assert!(app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .is_empty()); + assert_eq!(app.tabs[app.active_tab].scene.selected_constraint, None); + + app.undo_steps(1); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); } /// `named_parameters_design.md` stage 4: applying the PARAMETERS editor's @@ -7480,24 +8306,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(); @@ -7507,10 +8347,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:?}"), @@ -7518,7 +8367,47 @@ 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) + ); + } + + /// The no-selection page must expose the active drawing's named parameters. + #[test] + fn no_selection_properties_exposes_named_parameters() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + app.tabs[app.active_tab] + .scene + .named_parameters_mut() + .set("width", "12") + .unwrap(); + + app.refresh_properties(); + + assert!(app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .any(|property| matches!( + &property.value, + crate::scene::model::object::PropValue::ParamRow { name, .. } + if name == "width" + ))); + assert!(app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .any(|property| matches!( + property.value, + crate::scene::model::object::PropValue::ParamsVisibilityToggle(_) + ))); } /// End-to-end through the actual `Message` handlers a Properties-panel @@ -7534,41 +8423,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 @@ -7580,18 +8517,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])); } @@ -7606,15 +8549,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" ); } @@ -7628,15 +8592,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. @@ -7648,17 +8637,47 @@ mod sketch_constraint_undo_tests { let b = add_line(&mut app, 20.0, 0.0, 20.0, 5.0); // deliberately far from `a`, so the two picks are unambiguous let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end - point_b: glam::DVec3::new(20.0, 0.0, 0.0), // b's start + first: crate::command::CoincidentPick { + handle: Some(a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: Some(b), + point: glam::DVec3::new(20.0, 0.0, 0.0), + whole_curve: false, + }, + multiple: false, 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" + ); + let Some(acadrust::EntityType::Line(first)) = + app.tabs[app.active_tab].scene.document.get_entity(a) + else { + panic!("expected first line"); + }; + let Some(acadrust::EntityType::Line(second)) = + app.tabs[app.active_tab].scene.document.get_entity(b) + else { + panic!("expected second line"); + }; + assert!( + (first.end - acadrust::types::Vector3::new(5.0, 0.0, 0.0)).length() < 1.0e-7 + ); + assert!((second.start - first.end).length() < 1.0e-7); + assert!((second.length() - 5.0).abs() < 1.0e-7); } /// A pick that doesn't land near any real point (no object snap match) @@ -7671,15 +8690,24 @@ mod sketch_constraint_undo_tests { let _a = add_line(&mut app, 0.0, 0.0, 5.0, 0.0); let _ = app.apply_cmd_result(CmdResult::AddCoincidentConstraint { - point_a: glam::DVec3::new(5.0, 0.0, 0.0), // a's end — a real point - point_b: glam::DVec3::new(500.0, 500.0, 0.0), // nowhere near anything + first: crate::command::CoincidentPick { + handle: Some(_a), + point: glam::DVec3::new(5.0, 0.0, 0.0), + whole_curve: false, + }, + second: crate::command::CoincidentPick { + handle: None, + point: glam::DVec3::new(500.0, 500.0, 0.0), + whole_curve: false, + }, + multiple: false, label: "Coincident constraint", }); 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, @@ -7702,12 +8730,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 { @@ -7718,14 +8747,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(), @@ -7764,41 +8797,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] @@ -7812,12 +8897,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, @@ -7834,8 +8916,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..d021b1d6b 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); } @@ -1082,53 +1113,86 @@ impl OpenCADStudio { } } + "CONSTRAINTSETTINGS" => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(crate::app::ModalKind::AutoConstrainSettings); + } + "AUTOCONSTRAIN" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); + let sel = SelectObjectsCommand::auto_constrain(cmd); 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, + &self.auto_constrain_settings, + ); if inferred.is_empty() { - self.command_line - .push_output("No supported geometric relations were found."); - return None; - } - let before = self.tabs[i] - .scene - .sketch_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::sketch_constraints::SketchConstraintSet::new(scope) - }); - let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); - self.tabs[i] - .scene - .record_undo_sketch_constraints_before(scope, before); - let count = inferred.len(); - for (kind, refs) in inferred { + self.command_line.push_output( + format!( + "0 constraint(s) applied to {} object(s).", + handles.len() + ) + .as_str(), + ); + self.tabs[i].scene.deselect_all(); + self.refresh_selected_grips(); + } else { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .sketch_constraint_set_mut(scope) - .add(kind, refs, None); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(format!("{} constraint(s) applied.", count).as_str()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + .record_undo_parametric_constraints_before(scope, before); + let count = inferred.len(); + for (kind, refs) in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + format!( + "{} constraint(s) applied to {} object(s).", + count, + handles.len() + ) + .as_str(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } } @@ -1142,14 +1206,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 +1229,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 +1242,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 +1279,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,28 +1316,35 @@ 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, })); } } - "CCONSTRAINT" => { + "CCONSTRAINT" | "GCCOINCIDENT" => { use crate::modules::parametric::CoincidentConstraintCommand; let new_cmd = CoincidentConstraintCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "GEOMCONSTRAINT" => { + use crate::modules::parametric::GeomConstraintCommand; + let new_cmd = GeomConstraintCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); @@ -1280,7 +1352,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 +1366,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 +1397,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 +1428,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 +1455,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 +1489,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 +1513,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 +1548,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 +1562,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 +1729,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 +1839,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 +1877,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 +2033,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 +2076,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 +2116,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 +2135,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 +2220,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 +2244,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/commands/inquiry.rs b/src/app/commands/inquiry.rs index aa9c088a7..161a6c28c 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1,6 +1,82 @@ use super::*; impl OpenCADStudio { + /// Align every selected object or complete group by one edge or center. + fn align_selected_bounds(&mut self, i: usize, command: &str) { + use crate::command::EntityTransform; + use cadkernel::space::BoundsAlignment; + use glam::DVec3; + + let (alignment, label) = match command { + "ALIGNLEFT" => (BoundsAlignment::Left, "Align Left"), + "ALIGNHCENTER" => (BoundsAlignment::HorizontalCenter, "Align Horizontal Centers"), + "ALIGNRIGHT" => (BoundsAlignment::Right, "Align Right"), + "ALIGNTOP" => (BoundsAlignment::Top, "Align Top"), + "ALIGNVCENTER" => (BoundsAlignment::VerticalCenter, "Align Vertical Centers"), + "ALIGNBOTTOM" => (BoundsAlignment::Bottom, "Align Bottom"), + _ => return, + }; + + let handles: Vec<_> = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if handles.len() < 2 { + self.command_line + .push_info(crate::t!("Select objects").as_ref()); + return; + } + + let bounds: Vec<_> = self.tabs[i] + .scene + .selected_object_units(&handles) + .into_iter() + .filter_map(|unit| { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for handle in &unit { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + let bb = entity.as_entity().bounding_box(); + min_x = min_x.min(bb.min.x); + min_y = min_y.min(bb.min.y); + max_x = max_x.max(bb.max.x); + max_y = max_y.max(bb.max.y); + } + let bounds = [min_x, min_y, max_x, max_y]; + bounds + .iter() + .all(|value| value.is_finite()) + .then_some((unit, bounds)) + }) + .collect(); + if bounds.len() < 2 { + return; + } + + let extents: Vec<_> = bounds.iter().map(|(_, bounds)| *bounds).collect(); + let offsets = cadkernel::space::align_aabbs_2d(&extents, alignment) + .expect("validated at least two finite bounds"); + let pending = self.begin_undo(i, label, handles.len(), true); + for ((unit, _), [dx, dy]) in bounds.iter().zip(offsets) { + self.tabs[i].scene.transform_entities( + unit, + &EntityTransform::Translate(DVec3::new(dx, dy, 0.0)), + ); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.command_line.push_output( + crate::tf!("{command}: aligned {} object(s).", bounds.len()).as_ref(), + ); + } + pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "3DORBIT" => { @@ -980,6 +1056,11 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "ALIGNLEFT" | "ALIGNHCENTER" | "ALIGNRIGHT" | "ALIGNTOP" | "ALIGNVCENTER" + | "ALIGNBOTTOM" => { + self.align_selected_bounds(i, cmd); + } + "LENGTHEN" => { use crate::modules::draw::modify::lengthen::LengthenCommand; let cmd = LengthenCommand::new(); @@ -2211,3 +2292,97 @@ mod flatten_tests { ); } } + +#[cfg(test)] +mod align_selected_bounds_tests { + use super::*; + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + fn add_line(app: &mut OpenCADStudio, x1: f64, y1: f64, x2: f64, y2: f64) -> acadrust::Handle { + app.tabs[app.active_tab] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(x1, y1, 0.0), + Vector3::new(x2, y2, 0.0), + ))) + } + + fn line_start_x(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.x.min(l.end.x), + other => panic!("expected a Line, got {other:?}"), + } + } + + fn line_max_y(app: &OpenCADStudio, handle: acadrust::Handle) -> f64 { + match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(EntityType::Line(l)) => l.start.y.max(l.end.y), + other => panic!("expected a Line, got {other:?}"), + } + } + + /// A single selected object has no "other object" to align to — refuse + /// with an informational message rather than silently moving nothing + /// (or panicking on the empty-bounds case). + #[test] + fn refuses_with_fewer_than_two_selected_objects() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 0.0, 0.0, 2.0, 1.0); + app.tabs[app.active_tab].scene.select_entities(&[a]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, a), 0.0, "the lone object must not move"); + } + + /// ALIGNLEFT moves every selected object's left edge to the leftmost + /// edge already present in the selection; ALIGNTOP does the same for + /// the topmost edge. Both are undoable as one step. + #[test] + fn aligns_left_and_top_edges_and_is_undoable() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let left = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); // leftmost & topmost already + let right = add_line(&mut app, 5.0, -3.0, 8.0, -2.0); // further right and lower + app.tabs[app.active_tab].scene.select_entities(&[left, right]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + assert_eq!(line_start_x(&app, left), 0.0, "the already-leftmost object must not move"); + assert_eq!(line_start_x(&app, right), 0.0, "the other object's left edge should meet it"); + + app.dispatch_inquiry("ALIGNTOP", i); + assert_eq!(line_max_y(&app, left), 1.0, "the already-topmost object must not move"); + assert_eq!(line_max_y(&app, right), 1.0, "the other object's top edge should meet it"); + + app.undo_steps(1); // undo ALIGNTOP + assert_eq!(line_max_y(&app, right), -2.0, "undo should restore the pre-ALIGNTOP position"); + app.undo_steps(1); // undo ALIGNLEFT + assert_eq!(line_start_x(&app, right), 5.0, "undo should restore the original geometry"); + } + + /// A complete, fully-selected group (`Scene::selected_object_units`) + /// moves as one rigid unit instead of each member line sliding to meet + /// the *other* members of its own group. + #[test] + fn a_complete_group_aligns_as_one_rigid_unit() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let a = add_line(&mut app, 5.0, 0.0, 6.0, 1.0); + let b = add_line(&mut app, 7.0, 0.0, 8.0, 1.0); + let other = add_line(&mut app, 0.0, 0.0, 1.0, 1.0); + app.tabs[app.active_tab].scene.create_group("pair".to_string(), vec![a, b]); + app.tabs[app.active_tab].scene.select_entities(&[a, b, other]); + let i = app.active_tab; + + app.dispatch_inquiry("ALIGNLEFT", i); + // The group's own left edge (a's, at x=5) moves to meet `other`'s + // left edge (x=0): a shifts by -5, and b — sharing the same + // transform — must shift by exactly the same amount, not collapse + // onto `a`. + assert_eq!(line_start_x(&app, a), 0.0); + assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it"); + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 9fed425e9..751d2e5d2 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1046,6 +1046,9 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "CONSTRAINTSOLVEMODE" + | "CONSTRAINTINFER" + | "CONSTRAINTBARDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1068,7 +1071,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1262,6 +1265,50 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!( + name.as_str(), + "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + ) { + let current = match name.as_str() { + "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), + "CONSTRAINTINFER" => i16::from(self.constraint_infer), + "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, + _ => unreachable!(), + }; + let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 }; + if let Some(value) = &value { + match value + .parse::() + .ok() + .filter(|value| (0..=maximum).contains(value)) + { + Some(mode) => { + match name.as_str() { + "CONSTRAINTSOLVEMODE" => { + self.constraint_solve_mode = mode != 0 + } + "CONSTRAINTINFER" => self.constraint_infer = mode != 0, + "CONSTRAINTBARDISPLAY" => { + self.constraint_bar_display = mode + } + _ => unreachable!(), + } + self.persist_settings_if_changed(); + self.command_line + .push_output(&crate::tf!("{name} = {mode}")); + } + None => self.command_line.push_error(&crate::tf!( + "{name}: expected an integer from 0 to {maximum}." + )), + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "DONUTID" | "DONUTOD") { let current = if name == "DONUTID" { crate::modules::draw::defaults::get_donut_inner_diameter() diff --git a/src/app/control/transport.rs b/src/app/control/transport.rs index aa29f10ba..adce0eeb7 100644 --- a/src/app/control/transport.rs +++ b/src/app/control/transport.rs @@ -52,6 +52,7 @@ fn listen(sender: mpsc::Sender) -> std::io::Result<()> { } let mut file = options.open(&path)?; write!(file, "{descriptor}")?; + drop(file); let clients = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { if clients.fetch_add(1, Ordering::SeqCst) >= 8 { 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 c68bdfa0d..9a5b4ebb8 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; pub(crate) mod dim_viewport; @@ -31,7 +32,6 @@ mod startup; mod style_ops; mod text_inline; mod tolerance_dialog; -mod annotation_data; mod update; mod view; mod visibility; @@ -261,12 +261,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; @@ -553,14 +553,18 @@ 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, + pub auto_constrain_settings: settings::AutoConstrainSettings, + auto_constrain_saved: Option, + auto_constrain_selected_row: usize, + auto_constrain_distance_input: String, + auto_constrain_angle_input: String, + pub constraint_solve_mode: bool, + pub constraint_infer: bool, + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -1746,6 +1750,7 @@ pub enum ModalKind { DrawingUnits, GeometricTolerance, DraftingSettings, + AutoConstrainSettings, LayerStateEditor, Plot, PrintAll, @@ -2138,8 +2143,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), @@ -2567,9 +2570,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. @@ -2666,6 +2669,20 @@ pub enum Message { DraftingSettingsClose, DraftingSettingsCloseDiscard, DraftingSettingsCloseKeep, + AutoConstrainSelectRow(usize), + AutoConstrainToggleKind(settings::AutoConstraintKind), + AutoConstrainMoveUp, + AutoConstrainMoveDown, + AutoConstrainSelectAll, + AutoConstrainClearAll, + AutoConstrainReset, + AutoConstrainToggleTangentPoint, + AutoConstrainTogglePerpendicularIntersection, + AutoConstrainDistanceChanged(String), + AutoConstrainAngleChanged(String), + AutoConstrainApply, + AutoConstrainOk, + AutoConstrainCancel, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). @@ -2928,6 +2945,8 @@ pub enum Message { /// A Constraints-section row was clicked: select every entity in the /// list (replacing the current selection) in the viewport. PropConstraintLinkClick(Vec), + /// Remove one parametric constraint selected from Properties or the viewport. + PropConstraintDelete(crate::scene::parametric_constraints::ConstraintId), // ── About window ──────────────────────────────────────────────────── AboutOpen, // ── Graphics warning ──────────────────────────────────────────────── @@ -3656,8 +3675,15 @@ impl OpenCADStudio { dimension_continue_mode: 1, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, + auto_constrain_settings: settings::AutoConstrainSettings::default(), + auto_constrain_saved: None, + auto_constrain_selected_row: 0, + auto_constrain_distance_input: "0.05".to_string(), + auto_constrain_angle_input: "1".to_string(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 7096e7b03..a942c5ca6 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()], }, }, @@ -471,6 +484,41 @@ impl OpenCADStudio { }, ], }, + PropSection { + title: t!("Parameters").into_owned(), + props: { + let mut props = vec![Property { + label: String::new(), + field: "show_constraint_values", + value: PropValue::ParamsVisibilityToggle( + self.show_constraint_values, + ), + }]; + props.extend(scene + .named_parameters() + .iter() + .enumerate() + .map(|(index, parameter)| Property { + label: String::new(), + field: "named_parameter", + value: PropValue::ParamRow { + index, + name: parameter.name.clone(), + formula: parameter.source.clone(), + resolved: scene + .named_parameters() + .resolve(¶meter.name) + .map_err(|error| error.to_string()), + }, + })); + props.push(Property { + label: String::new(), + field: "named_parameter_add", + value: PropValue::ParamAddRow, + }); + props + }, + }, ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), @@ -478,7 +526,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 +543,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 +563,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 +577,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 +653,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 +836,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(", "), ), @@ -829,12 +882,21 @@ impl OpenCADStudio { } if !compact_solid { - sections.extend(crate::entities::object_data::sections( + let mut object_sections = crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, handle, entity, - )); + ); + let scope = self.tabs[i].current_parametric_scope(); + let constrained = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| set.constraints_touching(handle).next().is_some()); + if constrained { + object_sections.retain(|section| section.title != "Associative Data"); + } + sections.extend(object_sections); } { @@ -870,11 +932,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 +990,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 +1087,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 +1302,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 +1335,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 +1351,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 +1382,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 +1396,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 +1446,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 +1523,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 +1562,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 +1591,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 +1620,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 +1646,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 +1703,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 +1734,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 +1770,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 +1851,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 +1882,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 +1893,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 +1975,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 +2052,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 +2131,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,53 +2234,6 @@ impl OpenCADStudio { if compact_solid { retain_compact_solid_sections(&mut sections); } - // Persistent sketch-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 - // it relates (PropConstraintLinkClick). Omitted - // entirely when the entity has none — an unconstrained - // entity (the common case) shouldn't gain empty - // bookkeeping. - { - let scope = self.tabs[i].current_sketch_scope(); - let scene = &self.tabs[i].scene; - if let Some(set) = scene.sketch_constraint_set(scope) { - let props: Vec = set - .constraints_touching(handle) - .map(|c| { - 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)) => { - 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} (?)"), - } - } - 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(); - 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 }, - } - }) - .collect(); - if !props.is_empty() { - sections.push(crate::scene::model::object::PropSection { - title: t!("Constraints").into_owned(), - props, - }); - } - } - } let title = match entity { acadrust::EntityType::Insert(ins) => { let is_xref = self.tabs[i] @@ -2270,7 +2268,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 +2335,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 +2380,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 +2393,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 +2462,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 +2627,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 +2728,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); @@ -2756,6 +2753,25 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { + self.invalidate_property_targets_with_driven(i, handles, &[]); + } + + pub(super) fn invalidate_property_targets_with_driven( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + ) { + self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + } + + pub(super) fn invalidate_property_targets_with_originals( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retained_originals: &[(Handle, acadrust::EntityType)], + ) { let mut context_object_changed = false; for &handle in handles { // A dimension is drawn from the block holding its picture, and that @@ -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). @@ -2787,7 +2801,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities_with_parametric_originals( + &changes, + driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + retained_originals, + ); } /// Apply a single-property edit to every handle in `handles`, recording the @@ -2826,10 +2847,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 +2911,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 +3013,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 +3039,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 +3059,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 +3067,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 +3103,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 +3193,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 +3218,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() } @@ -3231,6 +3239,9 @@ fn make_sections_read_only( PropValue::EntityLink { handles, .. } => format!("{} entity link(s)", handles.len()), PropValue::ParamRow { name, formula, .. } => format!("{name} = {formula}"), PropValue::ParamAddRow => String::new(), + PropValue::ParamsVisibilityToggle(value) => { + if *value { t!("On") } else { t!("Off") }.into_owned() + } }; property.field = "locked_read_only"; property.value = PropValue::ReadOnly(text); @@ -3312,9 +3323,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 +3341,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 +3428,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 +3511,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 +3542,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 +3568,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 +3603,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 +3808,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 +3855,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 +3920,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 +4012,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 +4042,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 +4118,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 +4207,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 +4270,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 +4318,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 +4334,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 +4414,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..9c2757542 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,108 @@ use crate::snap::SnapType; use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AutoConstraintKind { + Coincident, + Collinear, + Parallel, + Perpendicular, + Tangent, + Concentric, + Horizontal, + Vertical, + Equal, +} + +impl AutoConstraintKind { + pub const ALL: [Self; 9] = [ + Self::Coincident, + Self::Collinear, + Self::Parallel, + Self::Perpendicular, + Self::Tangent, + Self::Concentric, + Self::Horizontal, + Self::Vertical, + Self::Equal, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Collinear => "Collinear", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Tangent => "Tangent", + Self::Concentric => "Concentric", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Equal => "Equal", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AutoConstrainSettings { + pub priority: Vec, + pub enabled: Vec, + pub tangent_must_share_point: bool, + pub perpendicular_must_intersect: bool, + pub distance_tolerance: f64, + pub angle_tolerance_deg: f64, +} + +impl Default for AutoConstrainSettings { + fn default() -> Self { + Self { + priority: AutoConstraintKind::ALL.to_vec(), + // Keep Equal available without creating redundant relations + // between equal-length segments by default. + enabled: AutoConstraintKind::ALL + .into_iter() + .filter(|kind| *kind != AutoConstraintKind::Equal) + .collect(), + tangent_must_share_point: true, + perpendicular_must_intersect: true, + distance_tolerance: 0.05, + angle_tolerance_deg: 1.0, + } + } +} + +impl AutoConstrainSettings { + pub fn sanitize(&mut self) { + let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len()); + for kind in self + .priority + .iter() + .copied() + .chain(AutoConstraintKind::ALL) + { + if !priority.contains(&kind) { + priority.push(kind); + } + } + self.priority = priority; + self.enabled + .retain(|kind| AutoConstraintKind::ALL.contains(kind)); + self.enabled.sort_by_key(|kind| { + self.priority + .iter() + .position(|candidate| candidate == kind) + .unwrap_or(usize::MAX) + }); + self.enabled.dedup(); + if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 { + self.distance_tolerance = 0.05; + } + if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 { + self.angle_tolerance_deg = 1.0; + } + } +} + /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { @@ -283,16 +385,25 @@ 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 /// canvas detail on a dense sketch. #[serde(default = "default_show_constraint_values")] pub show_constraint_values: bool, + /// Inference types, priority, intersection rules, and tolerances used by + /// the Auto Constrain command. + #[serde(default)] + pub auto_constrain: AutoConstrainSettings, + /// Keep existing geometry size while solving after a constraint edit. + #[serde(default = "default_constraint_solve_mode")] + pub constraint_solve_mode: bool, + /// Apply eligible geometric constraints while creating geometry. + #[serde(default)] + pub constraint_infer: bool, + /// Constraint bar display bit mask: 1 after applying, 2 on selection. + #[serde(default = "default_constraint_bar_display")] + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -363,6 +474,14 @@ fn default_show_constraint_values() -> bool { true } +fn default_constraint_solve_mode() -> bool { + true +} + +fn default_constraint_bar_display() -> i16 { + 3 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -418,8 +537,11 @@ impl Default for UserSettings { textfill: true, backup_on_save: true, file_assoc_enabled: true, - write_dwg_native_constraints: false, show_constraint_values: true, + auto_constrain: AutoConstrainSettings::default(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4dd845b72..8a3c081c4 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1999,10 +1999,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_ribbon_color_changed(&mut self, color: AcadColor) -> Task { - let i = self.active_tab; - self.ribbon.prop_color_palette_open = false; - self.ribbon.close_dropdown(); - let handles = self.property_target_handles(i); + let i = self.active_tab; + self.ribbon.prop_color_palette_open = false; + self.ribbon.close_dropdown(); + self.note_recent_color(color); + let handles = self.property_target_handles(i); if handles.is_empty() { if self.has_property_selection(i) { return Task::none(); @@ -3247,6 +3248,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { + let driven_marker = match field { + "start_x" | "start_y" | "start_z" => Some(0), + "end_x" | "end_y" | "end_z" => Some(1), + "center_x" | "center_y" | "center_z" => Some(-3), + _ => None, + }; + let retained_originals: Vec<_> = if self.constraint_solve_mode + && driven_marker.is_some() + { + handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect() + } else { + Vec::new() + }; let evaluates_expression = self.tabs[i] .properties .sections @@ -3563,7 +3587,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - self.invalidate_property_targets(i, &handles); + let driven_refs: Vec<_> = driven_marker + .into_iter() + .flat_map(|marker| { + handles.iter().copied().map(move |handle| { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + }) + }) + .collect(); + self.invalidate_property_targets_with_originals( + i, + &handles, + &driven_refs, + &retained_originals, + ); self.tabs[i].dirty = true; self.refresh_properties(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1bad3d3ce..8f7ca88f2 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,42 +80,13 @@ 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, } } -type LayoutPlotParams = ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); - -type ClippedPlotParams = ( - Vec, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); +use crate::io::pdf_export::{PdfPageInput, PlotContent}; fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; @@ -149,11 +117,8 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f } } -fn plot_content_extents( - wires: &[crate::io::pdf_export::PlotWire], - hatches: &[crate::scene::model::hatch_model::HatchModel], - wipeouts: &[crate::scene::model::hatch_model::HatchModel], -) -> Option<(f64, f64, f64, f64)> { +fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { + let PlotContent { wires, hatches, wipeouts, images, .. } = content; let mut bounds = ( f64::INFINITY, f64::INFINITY, @@ -168,14 +133,40 @@ fn plot_content_extents( bounds.3 = bounds.3.max(y); } }; - for wire in wires { + for wire in wires.iter().filter(|wire| wire.name != "paper_printable_area") { let [x0, y0, x1, y1] = wire.aabb; include(x0 as f64, y0 as f64); include(x1 as f64, y1 as f64); } 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, + ); + } + } + for plot in images { + let mut image_bounds = [f64::INFINITY, f64::INFINITY, + f64::NEG_INFINITY, f64::NEG_INFINITY]; + for vertex in &plot.image.verts { + for axis in 0..2 { + let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64; + image_bounds[axis] = image_bounds[axis].min(value); + image_bounds[axis + 2] = image_bounds[axis + 2].max(value); + } + } + for clip in &plot.clips { + for axis in 0..2 { + let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min); + let max = clip.iter().map(|p| p[axis]).fold(f64::NEG_INFINITY, f64::max); + image_bounds[axis] = image_bounds[axis].max(min); + image_bounds[axis + 2] = image_bounds[axis + 2].min(max); + } + } + if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] { + include(image_bounds[0], image_bounds[1]); + include(image_bounds[2], image_bounds[3]); } } (bounds.0.is_finite() @@ -191,12 +182,7 @@ fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, render_mode_override: Option, -) -> ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, -) { +) -> PlotContent { let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); let plot_viewport_borders = scene .effective_plot_settings() @@ -230,20 +216,23 @@ fn plot_scene_content( let model_wires = with_depth(model_wires); let paper_hatches = scene.paper_plot_hatches().as_ref().clone(); let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + let paper_images = scene.paper_plot_images(); if scene.current_layout == "Model" { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), + }; + return PlotContent { + wires: std::sync::Arc::new(paper_wires), + hatches: paper_hatches, + wipeouts: paper_wipeouts, + images: paper_images, + group_splits: splits, }; - return ( - std::sync::Arc::new(paper_wires), - paper_hatches, - paper_wipeouts, - splits, - ); } - let (mut model_pattern_wires, model_hatches, model_wipeouts) = + let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires @@ -251,36 +240,42 @@ fn plot_scene_content( .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) .collect::>(); - let (wires, hatches, wipeouts, splits) = if paper_space_last { + let (wires, hatches, wipeouts, images, splits) = if paper_space_last { let splits = crate::io::pdf_export::PlotGroupSplits { wires: model_wires.len() + model_pattern_wires.len(), hatches: model_hatches.len(), wipeouts: model_wipeouts.len(), + images: model_images.len(), }; let mut wires = model_wires; let mut hatches = model_hatches; let mut wipeouts = model_wipeouts; + let mut images = model_images; wires.extend(model_pattern_wires); wires.extend(paper_wires); hatches.extend(paper_hatches); wipeouts.extend(paper_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(paper_images); + (wires, hatches, wipeouts, images, splits) } else { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), }; let mut wires = paper_wires; let mut hatches = paper_hatches; let mut wipeouts = paper_wipeouts; + let mut images = paper_images; wires.extend(model_wires); wires.extend(model_pattern_wires); hatches.extend(model_hatches); wipeouts.extend(model_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(model_images); + (wires, hatches, wipeouts, images, splits) }; - (std::sync::Arc::new(wires), hatches, wipeouts, splits) + PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits } } impl OpenCADStudio { @@ -304,9 +299,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,8 +427,11 @@ 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, + auto_constrain: self.auto_constrain_settings.clone(), + constraint_solve_mode: self.constraint_solve_mode, + constraint_infer: self.constraint_infer, + constraint_bar_display: self.constraint_bar_display, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -516,8 +512,16 @@ 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.auto_constrain_settings = s.auto_constrain.clone(); + self.auto_constrain_settings.sanitize(); + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.constraint_solve_mode = s.constraint_solve_mode; + self.constraint_infer = s.constraint_infer; + self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); @@ -532,9 +536,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 +623,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 +724,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 +886,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 +957,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 +969,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 +1039,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 +1077,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 +1090,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 +1107,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 +1148,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 +1208,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 +1246,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 +1256,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 +1274,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 +1288,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,342 +1374,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 persisted constraints after installing the document. - self.tabs[i].scene.load_sketch_constraints_from_document(); - // named_parameters_design.md stage 2: same load-time hook, - // for the document-wide parameter table. - self.tabs[i].scene.load_named_parameters_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; } } @@ -1711,16 +1721,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"))] @@ -1837,11 +1840,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) @@ -1929,13 +1928,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; } @@ -1946,12 +1943,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, @@ -1973,22 +1973,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(); } @@ -1999,12 +1995,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, @@ -2022,9 +2021,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(), + ); } } } @@ -2035,9 +2034,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(); @@ -2057,8 +2054,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 @@ -2072,11 +2068,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) } @@ -2133,7 +2125,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 || { @@ -2192,12 +2186,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) @@ -2206,12 +2202,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); } @@ -2231,17 +2223,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(), @@ -2260,9 +2251,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(), @@ -2310,16 +2304,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 { @@ -2385,9 +2378,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), }; } @@ -2442,8 +2433,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) } @@ -2485,8 +2475,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) } @@ -2522,40 +2511,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 @@ -2566,10 +2556,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 { @@ -2599,110 +2587,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 @@ -2791,10 +2773,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)) } } } @@ -2807,9 +2786,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); @@ -2889,10 +2866,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); @@ -2962,10 +2936,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(), + ); } } @@ -2986,32 +2963,16 @@ 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(page) = self.direct_plot_page() 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 render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); 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, - ) + crate::io::pdf_export::export_pdf(&page, &worker_path) .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; @@ -3029,48 +2990,32 @@ 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(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); 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, - ) - .map(|_| {crate::tf!( - "Plotted window to {}", + crate::io::pdf_export::export_pdf(&page, &worker_path) + .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()) + .file_name() + .unwrap_or_default() + .to_string_lossy() + ) + .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), @@ -3147,19 +3092,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 { @@ -3190,10 +3135,10 @@ 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() { + let area_page = match dialog.area.as_str() { "Display" => self.display_plot_job(), "Extents" => self.extents_plot_job(), "Limits" => self.limits_plot_job(), @@ -3203,62 +3148,14 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = if dialog.area == "Layout" { - self.layout_plot_params_for("Layout") + let page = if dialog.area == "Layout" { + self.layout_plot_page_for("Layout") } else { - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; - ( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) + area_page.ok_or_else(|| { + crate::tf!("Layout '{name}' plot area is empty.").into_owned() + })? }; - pages.push(crate::io::pdf_export::PdfPageInput { - wires, - hatches, - wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - options: Self::pdf_plot_options(&dialog, group_splits), - plot_style, - }); + pages.push(page); } Ok(pages) })(); @@ -3269,11 +3166,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; @@ -3283,19 +3176,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() { @@ -3309,13 +3195,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) } @@ -3323,22 +3212,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() { @@ -3348,24 +3233,22 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let options = self.plot_print_options(&dialog, Default::default()); + let options = self.plot_print_options(&dialog); 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) } @@ -3382,12 +3265,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, { @@ -3402,42 +3280,31 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - /// Build the render inputs and page geometry for a full-layout plot: wires - /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, - /// draw-origin offset, rotation, unit scale, and optional clip. Shared by - /// PDF export, preview, and printer output so all three render identically. - pub(super) fn layout_plot_params(&self) -> LayoutPlotParams { - self.layout_plot_params_for(&self.plot_dialog.area) + /// Assemble a layout page for PDF export, preview, or printing. + pub(super) fn layout_plot_page(&self) -> PdfPageInput { + self.layout_plot_page_for(&self.plot_dialog.area) } - fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { + fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput { let i = self.active_tab; 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 mut content = 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 { - group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())] + if paper_space { + content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())] .iter() .filter(|wire| wire.name != "paper_printable_area") .count(); - std::sync::Arc::new( - source_wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect(), - ) - } else { - source_wires - }; + std::sync::Arc::make_mut(&mut content.wires) + .retain(|wire| wire.name != "paper_printable_area"); + } if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { // Paper geometry is stored in layout units; PDF pages use mm. // Scale both geometry and offsets by the same unit conversion. @@ -3457,21 +3324,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(&content).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 { @@ -3492,11 +3350,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, @@ -3511,11 +3365,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. @@ -3545,30 +3395,31 @@ 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 }; - return ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + return PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - ); + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }; } // Model space keeps its established extents + 5% margin behaviour. @@ -3588,65 +3439,52 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { (297.0, 210.0, 0.0, 0.0) }; - ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - 0, - 1.0, - None, - ) + rotation_deg: 0, + scale: 1.0, + clip: None, + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + } } /// Build direct PDF/printer output using the physical layout sheet in /// paper space and the selected ISO sheet/scale around Model extents. - fn direct_plot_params(&self) -> Option { + fn direct_plot_page(&self) -> Option { if self.tabs[self.active_tab].scene.current_layout != "Model" { - return Some(self.layout_plot_params_for("Layout")); + return Some(self.layout_plot_page_for("Layout")); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.extents_plot_job()?; - if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() { + let page = self.extents_plot_job()?; + let content = &page.content; + if content.wires.is_empty() && content.hatches.is_empty() + && content.wipeouts.is_empty() && content.images.is_empty() + { return None; } - Some(( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - )) + Some(page) } 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(page) = self.direct_plot_page() 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()); + let options = self.plot_print_options(&self.plot_dialog); + 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( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, options, - )) + crate::io::print_to_printer::print_wires_with(page, options), + ) }, Message::PrintResult, ) @@ -3883,7 +3721,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() @@ -3919,11 +3759,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() @@ -3966,9 +3803,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 } @@ -4044,9 +3879,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 => { @@ -4129,7 +3965,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); @@ -4150,10 +3988,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; @@ -4202,7 +4043,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(); @@ -4217,8 +4062,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)) { @@ -4270,11 +4114,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); @@ -4340,9 +4180,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(( @@ -4354,17 +4192,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 @@ -4383,8 +4224,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(); } @@ -4508,25 +4348,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. @@ -4537,7 +4368,6 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_modal = None; self.reset_modal_geometry(); - let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. if d.area != "Layout" { @@ -4551,19 +4381,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let Some(( - w_wires, - w_hatches, - w_wipeouts, - wgroup_splits, - sw, - sh, - wox, - woy, - wrotation, - wscale, - wclip, - )) = job + let Some(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); @@ -4574,14 +4392,11 @@ pub(super) fn on_open_file(&mut self) -> Task { // whatever the output target is. if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - 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()) + crate::io::pdf_export::export_pdf(&page, &tmp) + .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); } @@ -4590,43 +4405,27 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotWindowExport); } let tmp = crate::io::print_to_printer::temp_pdf_path("print"); - let render_options = Self::pdf_plot_options(&d, wgroup_splits); - let opts = self.plot_print_options(&d, wgroup_splits); + let opts = self.plot_print_options(&d); 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)) + crate::io::pdf_export::export_pdf(&page, &tmp) + .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); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.layout_plot_params(); + let page = self.layout_plot_page(); if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - 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()) + crate::io::pdf_export::export_pdf(&page, &tmp) + .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,14 +4435,13 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotExport); } - let opts = self.plot_print_options(&d, group_splits); - self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); + let opts = self.plot_print_options(&d); + 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, - )) + iced::futures::executor::block_on(crate::io::print_to_printer::print_wires_with( + page, opts, + )) .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; @@ -4655,19 +4453,16 @@ pub(super) fn on_open_file(&mut self) -> Task { fn plot_print_options( &self, d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::print_to_printer::PrintOptions { crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), quality: Some(d.quality.clone()), - render: Self::pdf_plot_options(d, group_splits), } } fn pdf_plot_options( d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::pdf_export::PdfPlotOptions { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, @@ -4675,7 +4470,6 @@ pub(super) fn on_open_file(&mut self) -> Task { transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, - group_splits, } } @@ -4692,22 +4486,20 @@ pub(super) fn on_open_file(&mut self) -> Task { .cloned() } - fn window_plot_job(&self) -> Option { + fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } - fn display_plot_job(&self) -> Option { + fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } - fn limits_plot_job(&self) -> Option { - let (min, max) = self.tabs[self.active_tab] - .scene - .current_drawing_limits()?; + fn limits_plot_job(&self) -> Option { + let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } - fn named_view_plot_job(&self, name: &str) -> Option { + fn named_view_plot_job(&self, name: &str) -> Option { let view = self.tabs[self.active_tab] .scene .document @@ -4715,7 +4507,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; @@ -4728,32 +4521,18 @@ pub(super) fn on_open_file(&mut self) -> Task { )) } - fn extents_plot_job(&self) -> Option { + fn extents_plot_job(&self) -> Option { 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 extents = plot_content_extents( - &wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect::>(), - &hatches, - &wipeouts, - )?; + let content = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); + let extents = plot_content_extents(&content)?; self.area_plot_job(extents) } @@ -4793,12 +4572,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); @@ -4815,7 +4589,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. - fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { + fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { use crate::io::paper_sizes::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; @@ -4854,24 +4628,22 @@ 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 first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] + let mut content = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); + let in_window = |wire: &crate::io::pdf_export::PlotWire| { + wire.name != "paper_printable_area" + && wire.aabb[0] <= wx1 && wire.aabb[2] >= wx0 + && wire.aabb[1] <= wy1 && wire.aabb[3] >= wy0 + }; + content.group_splits.wires = content.wires + [..content.group_splits.wires.min(content.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(|wire| in_window(wire)) .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) - .cloned() - .collect(); - group_splits.wires = first_wire_count; + std::sync::Arc::make_mut(&mut content.wires).retain(in_window); let offset_x = (target_x / scale) - x0; let offset_y = (target_y / scale) - y0; let clip = Some(( @@ -4885,95 +4657,96 @@ pub(super) fn on_open_file(&mut self) -> Task { 90 | 270 => (sheet_h, sheet_w), _ => (sheet_w, sheet_h), }; - Some(( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + Some(PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - )) + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }) } 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 6d97aedca..e2e960738 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -131,9 +131,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(); @@ -348,10 +346,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")] @@ -380,14 +375,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 @@ -408,7 +418,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); @@ -462,18 +473,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() { @@ -511,10 +518,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() } }; @@ -544,10 +551,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) + }) } } @@ -576,9 +582,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) { @@ -618,9 +622,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 { @@ -701,9 +703,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() } @@ -722,17 +723,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) { @@ -781,15 +779,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), @@ -825,9 +822,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() { @@ -847,9 +842,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() } }; @@ -880,7 +878,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 @@ -913,9 +912,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() } }, @@ -944,7 +944,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() } @@ -969,9 +970,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(); @@ -982,14 +983,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); @@ -1011,7 +1008,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"]) @@ -1038,7 +1038,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() } @@ -1068,10 +1069,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()), @@ -1158,8 +1159,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 @@ -1198,10 +1200,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() } @@ -1210,8 +1214,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( @@ -1235,10 +1240,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() } @@ -1263,11 +1270,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 @@ -1284,9 +1294,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(), + ); } } } @@ -1297,8 +1307,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; @@ -1384,15 +1395,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() } @@ -1513,9 +1525,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() @@ -1527,8 +1537,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(); }; @@ -1585,8 +1594,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(|_| { @@ -1606,8 +1616,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() } } @@ -1617,25 +1629,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() } } @@ -1652,7 +1668,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 @@ -1693,15 +1713,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 @@ -1817,16 +1838,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, @@ -1861,12 +1880,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 { @@ -1909,10 +1924,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); } @@ -1930,8 +1942,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() } @@ -1948,10 +1959,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() @@ -2115,8 +2132,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] @@ -2132,13 +2150,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) { @@ -2182,7 +2199,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 }); } } @@ -2296,17 +2315,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()); @@ -2322,15 +2342,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 => { @@ -2363,9 +2384,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 => { @@ -2417,9 +2438,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 @@ -2451,12 +2470,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) => { @@ -2544,17 +2563,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()); @@ -2630,8 +2650,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; } @@ -2639,11 +2662,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(); } } @@ -2670,8 +2696,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; } @@ -2679,11 +2708,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(); } @@ -2706,8 +2738,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; } @@ -2715,11 +2750,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(); } } @@ -2757,9 +2795,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); @@ -2776,7 +2812,7 @@ impl OpenCADStudio { } Task::none() - }, + } Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => { self.on_layer_toggle_vp_freeze(layer_idx, vp_col_idx) @@ -2957,14 +2993,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; } @@ -3177,7 +3214,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() } @@ -3232,11 +3270,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 { @@ -3284,9 +3324,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!( @@ -3315,11 +3355,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; } } @@ -3372,10 +3409,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() } @@ -3438,8 +3474,8 @@ impl OpenCADStudio { } Task::none() } - Message::ResolveOneSketchConflict => { - self.resolve_one_sketch_conflict(); + Message::ResolveOneParametricConflict => { + self.resolve_one_parametric_conflict(); Task::none() } Message::TogglePolar => { @@ -3628,12 +3664,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() } @@ -3646,9 +3685,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, @@ -3698,8 +3738,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); @@ -3724,8 +3766,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. @@ -3840,8 +3884,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() @@ -3854,10 +3897,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); @@ -4083,7 +4124,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; @@ -4248,6 +4290,112 @@ impl OpenCADStudio { self.drafting_settings_close_confirm = false; Task::none() } + Message::AutoConstrainSelectRow(index) => { + if index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_selected_row = index; + } + Task::none() + } + Message::AutoConstrainToggleKind(kind) => { + if let Some(index) = self + .auto_constrain_settings + .enabled + .iter() + .position(|candidate| *candidate == kind) + { + self.auto_constrain_settings.enabled.remove(index); + } else { + self.auto_constrain_settings.enabled.push(kind); + } + Task::none() + } + Message::AutoConstrainMoveUp => { + let index = self.auto_constrain_selected_row; + if index > 0 && index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index - 1); + self.auto_constrain_selected_row -= 1; + } + Task::none() + } + Message::AutoConstrainMoveDown => { + let index = self.auto_constrain_selected_row; + if index + 1 < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index + 1); + self.auto_constrain_selected_row += 1; + } + Task::none() + } + Message::AutoConstrainSelectAll => { + self.auto_constrain_settings.enabled = + super::settings::AutoConstraintKind::ALL.to_vec(); + Task::none() + } + Message::AutoConstrainClearAll => { + self.auto_constrain_settings.enabled.clear(); + Task::none() + } + Message::AutoConstrainReset => { + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + Task::none() + } + Message::AutoConstrainToggleTangentPoint => { + self.auto_constrain_settings.tangent_must_share_point = + !self.auto_constrain_settings.tangent_must_share_point; + Task::none() + } + Message::AutoConstrainTogglePerpendicularIntersection => { + self.auto_constrain_settings.perpendicular_must_intersect = + !self.auto_constrain_settings.perpendicular_must_intersect; + Task::none() + } + Message::AutoConstrainDistanceChanged(value) => { + self.auto_constrain_distance_input = value; + Task::none() + } + Message::AutoConstrainAngleChanged(value) => { + self.auto_constrain_angle_input = value; + Task::none() + } + action @ (Message::AutoConstrainApply | Message::AutoConstrainOk) => { + let distance = self.auto_constrain_distance_input.trim().parse::(); + let angle = self.auto_constrain_angle_input.trim().parse::(); + match (distance, angle) { + (Ok(distance), Ok(angle)) + if distance.is_finite() + && distance >= 0.0 + && angle.is_finite() + && angle >= 0.0 => + { + self.auto_constrain_settings.distance_tolerance = distance; + self.auto_constrain_settings.angle_tolerance_deg = angle; + self.auto_constrain_settings.sanitize(); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); + self.persist_settings_if_changed(); + if matches!(action, Message::AutoConstrainOk) { + self.close_active_modal(); + } + } + _ => self.command_line.push_error( + crate::t!("Distance and angle tolerances must be non-negative numbers.") + .as_ref(), + ), + } + Task::none() + } + Message::AutoConstrainCancel => { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + self.close_active_modal(); + Task::none() + } // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { @@ -4288,6 +4436,13 @@ impl OpenCADStudio { } let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; + // A selected constraint-glyph pill takes Delete before entity + // erase — the two selections are mutually exclusive (see + // `Scene::selected_constraint`). + if let Some(id) = self.tabs[i].scene.selected_constraint { + self.delete_parametric_constraint(id); + return Task::none(); + } let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect(); if !handles.is_empty() { // Erase is delta-safe unless a target is in a group (group @@ -4623,9 +4778,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 => { @@ -4743,8 +4897,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() } @@ -4791,11 +4946,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; } } @@ -4808,9 +4964,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 @@ -4823,11 +4977,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; } } @@ -4839,11 +4994,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; } } @@ -4908,7 +5064,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()) } @@ -4945,8 +5102,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()); @@ -4986,9 +5142,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); } }); @@ -5008,12 +5162,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() @@ -5028,9 +5178,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); } }); @@ -5052,9 +5200,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); } }); @@ -5096,12 +5242,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() @@ -5121,11 +5263,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", )); @@ -5245,8 +5386,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 { @@ -5267,16 +5407,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, @@ -5332,7 +5473,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) @@ -5473,8 +5618,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); @@ -5580,8 +5724,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)) = @@ -5808,8 +5951,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(); @@ -5847,7 +5991,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 { @@ -5858,8 +6003,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() } @@ -5884,7 +6030,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() } @@ -5967,8 +6115,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); @@ -6154,10 +6301,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() } @@ -6168,7 +6314,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() @@ -6184,7 +6333,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) => { @@ -6200,7 +6350,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 @@ -6210,6 +6364,10 @@ impl OpenCADStudio { Message::PropParamCommit { index, field } => self.on_prop_param_commit(index, field), Message::PropParamDelete(index) => self.on_prop_param_delete(index), Message::PropParamAddNew => self.on_prop_param_add_new(), + Message::PropConstraintDelete(id) => { + self.delete_parametric_constraint(id); + Task::none() + } Message::PropConstraintLinkClick(handles) => { let i = self.active_tab; self.tabs[i].scene.deselect_all(); @@ -6219,7 +6377,6 @@ impl OpenCADStudio { self.refresh_properties(); Task::none() } - // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { self.active_modal = Some(super::ModalKind::Options); @@ -6284,8 +6441,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() } @@ -6295,9 +6451,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(); @@ -6522,7 +6676,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() } @@ -6664,9 +6819,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(); @@ -6742,11 +6895,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(); @@ -6816,6 +6964,11 @@ impl OpenCADStudio { return Task::none(); } self.drafting_settings_close_confirm = false; + if self.active_modal == Some(super::ModalKind::AutoConstrainSettings) { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + } let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { @@ -6834,9 +6987,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 = @@ -6915,10 +7068,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() } @@ -6951,9 +7102,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(), + ); } } } @@ -7090,13 +7241,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(); @@ -7232,7 +7381,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() { @@ -7272,13 +7423,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()) @@ -7292,7 +7445,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()) @@ -7450,12 +7604,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() @@ -7501,7 +7657,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"))] { @@ -7520,7 +7677,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) { @@ -7533,7 +7691,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(); } } } @@ -7645,8 +7804,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() { @@ -7744,14 +7904,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(), @@ -7801,10 +7954,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 } @@ -7992,7 +8143,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() } @@ -8003,21 +8155,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() @@ -8027,7 +8185,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() } @@ -8138,62 +8297,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")) @@ -8220,12 +8376,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() @@ -8672,18 +8830,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, @@ -8833,12 +9001,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 => { @@ -8965,28 +9132,30 @@ impl OpenCADStudio { } Message::ColorWindowPick(color) => { - // Keep only real colours in the recent list. ByLayer / ByBlock / None - // are logical CAD states rather than reusable colours. - if matches!( - &color, - 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) { - self.recent_colors.remove(pos); - } - - self.recent_colors.insert(0, color.clone()); - self.recent_colors.truncate(12); - } - + self.note_recent_color(color); self.on_color_window_pick(color) } Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } + pub(crate) fn note_recent_color(&mut self, color: acadrust::types::Color) { + // Keep only real colours in the recent list. ByLayer / ByBlock / None + // are logical CAD states rather than reusable colours. + if matches!( + &color, + 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) { + self.recent_colors.remove(pos); + } + + self.recent_colors.insert(0, color); + self.recent_colors.truncate(12); + } + } + /// Load a named scale into the scale-manager editor buffers (name + the /// paper / drawing units); blank ratios when the scale isn't found. fn load_scale_editor(&mut self, name: &str) { @@ -9244,7 +9413,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 bd2708751..7d0f11ec0 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(); } @@ -942,6 +933,13 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + let constraint_hover = self + .constraint_glyph_under(i, p) + .map(|(_, handles)| handles) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_constraint_hover_highlights(&constraint_hover); // Modifier-driven selection must be known before cursor_plane/axis and // drafting constraints are read, not merely before the final callback. if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -1066,9 +1064,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 +1291,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 +1299,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 +1311,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 +1374,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 +1421,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 +1453,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 +1463,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 +1510,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 +1536,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 +1573,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 +1649,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 +1685,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 +1710,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 +1725,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)) }) @@ -1790,13 +1744,36 @@ impl OpenCADStudio { ); } } + let driven_refs: Vec<_> = grip + .targets + .iter() + .filter_map(|target| { + let entity = self.tabs[i] + .scene + .document + .get_entity(target.handle)?; + crate::scene::parametric_constraints::driven_ref_for_grip( + entity, + target.handle, + target.grip_id, + ) + }) + .collect(); // 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, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + &self.grip_originals, + ); 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 +1825,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 +1872,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 +1919,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 +2041,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) }) }; @@ -2215,10 +2184,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()), @@ -2236,54 +2202,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() @@ -2294,25 +2259,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; @@ -2512,10 +2483,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); } @@ -2663,8 +2641,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() { @@ -2672,10 +2649,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() } @@ -2683,7 +2657,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(); @@ -2715,6 +2691,7 @@ impl OpenCADStudio { // viewport so it doesn't stick while the mouse is over the // ribbon / panels. self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_constraint_hover_highlights(&[]); // Don't touch `context_menu` here. ViewportExit also fires // when an upper overlay (the right-click menu panel) takes // the cursor, so clearing the menu state on every exit @@ -2832,9 +2809,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() { @@ -2895,9 +2880,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) @@ -2944,9 +2928,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 @@ -2956,8 +2939,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(); @@ -3022,6 +3004,34 @@ impl OpenCADStudio { Task::none() } + fn constraint_glyph_under( + &self, + i: usize, + cursor: iced::Point, + ) -> Option<( + crate::scene::parametric_constraints::ConstraintKind, + Vec, + )> { + if self.tabs[i].scene.current_layout != "Model" { + return None; + } + let vp_size = self.tabs[i].scene.selection.borrow().vp_size; + let scope = self.tabs[i].current_parametric_scope(); + let id = self.tabs[i].scene.constraint_glyph_hit( + scope, + vp_size, + self.show_constraint_values, + self.constraint_bar_display, + cursor, + )?; + let set = self.tabs[i].scene.parametric_constraint_set(scope)?; + let constraint = set.get(id)?; + let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + Some((constraint.kind, handles)) + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -3077,6 +3087,23 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + if self.tabs[i].active_cmd.is_none() { + if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entities(&handles); + self.refresh_selected_grips(); + self.refresh_properties(); + self.command_line + .push_info(&format!("{kind:?} constraint selected.")); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + return Task::none(); + } + } + // An engaged grip owns the next left press (click-move-click placement // or the release of a press-drag). Do not let the same press arm the // normal box/lasso state or trigger another viewport control; the @@ -3092,10 +3119,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(); @@ -3274,13 +3298,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] @@ -3322,8 +3341,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); } } @@ -3348,10 +3372,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; @@ -3408,8 +3429,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; @@ -3421,17 +3445,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) { @@ -3520,12 +3550,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 @@ -3787,7 +3812,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; @@ -3795,8 +3821,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 { @@ -3957,45 +3986,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] @@ -4039,11 +4082,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, @@ -4099,11 +4142,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() @@ -4135,7 +4183,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) { @@ -4273,8 +4322,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); } @@ -4384,8 +4432,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 @@ -4447,8 +4494,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); @@ -4459,8 +4505,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() { @@ -4528,163 +4573,186 @@ properties={:.1}ms picked={}", sel.box_current = None; } else { if box_anchor.is_none() { - let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); - let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let t_arm = crate::perf::enabled().then(Instant::now); - let prior_selection = self.tabs[i].scene.selected.len(); - let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( - all_wires, - click_world, - view_rot, - eye, - bounds, - crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, - ); - let candidate_handles = self.tabs[i] - .scene - .interaction_candidate_handles(&click_candidates); - - // Selection cycling: where two or more objects - // overlap, open a list box to pick which one; a - // single object falls through to the normal click. - // Gated behind the toggle, so default picking is - // unchanged when off. - let mut handled_by_cycling = false; - if self.selection_cycling { - let cands: Vec = scene::pick::hit_test::click_hits_all( - p, - &click_candidates, - view_rot, - eye, - bounds, - self.tabs[i].scene.document.header.lineweight_display, - crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ) - .into_iter() - .filter_map(|s| Scene::handle_from_wire_name(s)) - .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) - .collect(); - if cands.len() >= 2 { - // Overlap: open the list box at the cursor. - self.cycle_candidates = Some((p_full, cands)); - handled_by_cycling = true; - } + // A parametric-constraint glyph draws above its geometry + // and takes click priority over the entity beneath it. + let glyph_hit = (self.tabs[i].scene.current_layout == "Model") + .then(|| { + let scope = self.tabs[i].current_parametric_scope(); + self.tabs[i].scene.constraint_glyph_hit( + scope, + canvas_sz, + self.show_constraint_values, + self.constraint_bar_display, + p_full, + ) + }) + .flatten(); + if let Some(id) = glyph_hit { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.selected_constraint = Some(id); + self.refresh_properties(); + selection_just_completed = true; } - - if !handled_by_cycling { - let hit = scene::pick::hit_test::click_hit( - p, - &click_candidates, + if glyph_hit.is_none() { + let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); + let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); + let t_arm = crate::perf::enabled().then(Instant::now); + let prior_selection = self.tabs[i].scene.selected.len(); + let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( + all_wires, + click_world, view_rot, eye, bounds, - self.tabs[i].scene.document.header.lineweight_display, - crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ) - .and_then(|s| Scene::handle_from_wire_name(s)) - .or_else(|| { - scene::pick::hit_test::click_hit_hatch( + crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0, + ); + let candidate_handles = self.tabs[i] + .scene + .interaction_candidate_handles(&click_candidates); + + // Selection cycling: where two or more objects + // overlap, open a list box to pick which one; a + // single object falls through to the normal click. + // Gated behind the toggle, so default picking is + // unchanged when off. + let mut handled_by_cycling = false; + if self.selection_cycling { + let cands: Vec = scene::pick::hit_test::click_hits_all( p, - &self.tabs[i] - .scene - .visible_hatches_for_click(candidate_handles.as_ref()), + &click_candidates, view_rot, eye, bounds, - candidate_handles.as_ref(), - ) - }) - .or_else(|| { - // Block-internal hatch: resolve to the parent Insert. - scene::pick::hit_test::click_hit_insert_hatch( - p, - self.tabs[i].scene.insert_hatches_for_click().as_ref(), - view_rot, - eye, - bounds, - candidate_handles.as_ref(), - ) - }) - .or_else(|| { - // Normal 3D selection follows the displayed B-rep - // edges. Face-interior picking remains reserved for - // modelling commands that explicitly request it. - self.tabs[i].scene.solid_edge_click_hit( - p, - view_rot, - eye, - bounds, - candidate_handles.as_ref(), + self.tabs[i].scene.document.header.lineweight_display, crate::ui::overlay::pick_box_aperture_px(self.pick_box), ) - }); - // Selection filter: drop a pick whose type is excluded. - let hit = hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h)); - if let Some(handle) = hit { - // Individual picks accumulate (issue #47): - // each plain click adds to the selection, - // Shift+click removes the picked entity. - // PICKADD 0 (#226): a plain click - // REPLACES the selection instead and - // Shift+click toggles membership. - if self.shift_down || self.select_remove_mode { - // Remove was asked for by name, so it only - // ever takes away — the toggle below is - // Shift's PICKADD-0 behaviour, not its. - if !self.select_remove_mode - && !selection_pick_add - && !self.tabs[i].scene.selected.contains(&handle) - { - self.tabs[i].scene.select_entity(handle, false); - self.tabs[i].scene.expand_selection_for_groups(&[handle]); + .into_iter() + .filter_map(|s| Scene::handle_from_wire_name(s)) + .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) + .collect(); + if cands.len() >= 2 { + // Overlap: open the list box at the cursor. + self.cycle_candidates = Some((p_full, cands)); + handled_by_cycling = true; + } + } + + if !handled_by_cycling { + let hit = scene::pick::hit_test::click_hit( + p, + &click_candidates, + view_rot, + eye, + bounds, + self.tabs[i].scene.document.header.lineweight_display, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + .and_then(|s| Scene::handle_from_wire_name(s)) + .or_else(|| { + scene::pick::hit_test::click_hit_hatch( + p, + &self.tabs[i] + .scene + .visible_hatches_for_click(candidate_handles.as_ref()), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + }) + .or_else(|| { + // Block-internal hatch: resolve to the parent Insert. + scene::pick::hit_test::click_hit_insert_hatch( + p, + self.tabs[i].scene.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + ) + }) + .or_else(|| { + // Normal 3D selection follows the displayed B-rep + // edges. Face-interior picking remains reserved for + // modelling commands that explicitly request it. + self.tabs[i].scene.solid_edge_click_hit( + p, + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + }); + // Selection filter: drop a pick whose type is excluded. + let hit = + hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h)); + if let Some(handle) = hit { + // Individual picks accumulate (issue #47): + // each plain click adds to the selection, + // Shift+click removes the picked entity. + // PICKADD 0 (#226): a plain click + // REPLACES the selection instead and + // Shift+click toggles membership. + if self.shift_down || self.select_remove_mode { + // Remove was asked for by name, so it only + // ever takes away — the toggle below is + // Shift's PICKADD-0 behaviour, not its. + if !self.select_remove_mode + && !selection_pick_add + && !self.tabs[i].scene.selected.contains(&handle) + { + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].scene.expand_selection_for_groups(&[handle]); + } else { + self.tabs[i].scene.deselect_entity(handle); + } } else { - self.tabs[i].scene.deselect_entity(handle); + self.tabs[i] + .scene + .select_entity(handle, !selection_pick_add); + self.tabs[i].scene.expand_selection_for_groups(&[handle]); } - } else { - self.tabs[i] - .scene - .select_entity(handle, !selection_pick_add); - self.tabs[i].scene.expand_selection_for_groups(&[handle]); - } - self.refresh_properties(); - selection_just_completed = true; - } else { - // Empty-space click only ARMS a box here; it - // no longer clears the selection, so a box can - // add to it (issue #83). The box completion - // (or Esc) decides what happens to the - // selection. - // PICKADD 0 (#226): OS convention — the - // empty click also drops the selection. - if !selection_pick_add && !self.shift_down { - self.tabs[i].scene.deselect_all(); self.refresh_properties(); - } - // Pin the anchor to the world point under it - // so a zoom/pan mid-drag re-projects it - // instead of leaving it frozen in pixels - // (#234). Computed before the selection - // borrow so the &self projection can't clash. - let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds); - if let Some(t) = t_arm { - let arm_ms = t.elapsed().as_secs_f64() * 1000.0; - if arm_ms >= 5.0 { - crate::perf_record!( - "[perf] select-arm {arm_ms:>7.1}ms pick+clear, \ + selection_just_completed = true; + } else { + // Empty-space click only ARMS a box here; it + // no longer clears the selection, so a box can + // add to it (issue #83). The box completion + // (or Esc) decides what happens to the + // selection. + // PICKADD 0 (#226): OS convention — the + // empty click also drops the selection. + if !selection_pick_add && !self.shift_down { + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + } + // Pin the anchor to the world point under it + // so a zoom/pan mid-drag re-projects it + // instead of leaving it frozen in pixels + // (#234). Computed before the selection + // borrow so the &self projection can't clash. + let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds); + if let Some(t) = t_arm { + let arm_ms = t.elapsed().as_secs_f64() * 1000.0; + if arm_ms >= 5.0 { + crate::perf_record!( + "[perf] select-arm {arm_ms:>7.1}ms pick+clear, \ was_selected={}", - prior_selection, - ); + prior_selection, + ); + } + } + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + // Full-canvas space: ViewportMove updates + // box_current in canvas coords and the overlay + // draws there; release maps back into the tile. + sel.box_anchor = Some(p_full); + sel.box_current = Some(p_full); + sel.box_anchor_world = Some(anchor_world); + if !sel.box_crossing_locked { + sel.box_crossing = false; } - } - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - // Full-canvas space: ViewportMove updates - // box_current in canvas coords and the overlay - // draws there; release maps back into the tile. - sel.box_anchor = Some(p_full); - sel.box_current = Some(p_full); - sel.box_anchor_world = Some(anchor_world); - if !sel.box_crossing_locked { - sel.box_crossing = false; } } } @@ -4719,10 +4787,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(); @@ -4771,8 +4836,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 @@ -4839,8 +4903,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 @@ -4856,8 +4919,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() { @@ -4979,9 +5041,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 @@ -5132,10 +5197,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 @@ -5202,8 +5264,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; } @@ -5257,7 +5322,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() } @@ -5362,8 +5428,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(); } @@ -5504,8 +5569,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); @@ -5598,27 +5664,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() { @@ -5627,17 +5706,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; @@ -5782,10 +5870,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) } @@ -5936,11 +6021,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 @@ -5978,9 +6059,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]); } @@ -6009,14 +6091,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 { @@ -6025,11 +6106,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(); @@ -6065,8 +6148,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"}"#); @@ -6077,37 +6160,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()); @@ -6121,10 +6225,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"] { @@ -6134,10 +6238,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); @@ -6150,12 +6252,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); @@ -6201,14 +6311,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..0e3c7fdae 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,75 +809,41 @@ 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() - }); - // Constraint glyphs are displayed in model space only. - let constraint_glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = if is_paper { + let point_cursor = tab + .active_cmd + .as_ref() + .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); + let constraint_glyphs: Vec<( + iced::Point, + [f32; 2], + String, + bool, + bool, + Vec, + )> = 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) { - Some(set) if !set.constraints.is_empty() => { - 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 (view_rot, eye) = if let Some((cam, _)) = &edit_frame { - (cam.view_proj_rte(bounds), cam.eye()) - } else { - let cam = tab.scene.camera.borrow(); - (cam.view_proj_rte(bounds), cam.eye()) - }; - set.constraints - .iter() - .filter(|c| c.enabled) - .filter(|c| tab.scene.is_sketch_constraint_visible(scope, c.id)) - .filter_map(|c| { - let (anchor, outward) = - crate::scene::sketch_constraints::glyph_placement( - &tab.scene.document, - c, - )?; - let screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new(anchor.x, anchor.y, anchor.z), - view_rot, - eye, - bounds, - )?; - let outward_screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new( - anchor.x + outward.x, - anchor.y + outward.y, - anchor.z + outward.z, - ), - view_rot, - 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 is_conflicting = - set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if self.show_constraint_values { - crate::scene::sketch_constraints::glyph_label(c) - } else { - c.kind.glyph_symbol().to_string() - }; - point.x.is_finite().then(|| { - (point, direction.to_array(), label, is_conflicting) - }) - }) - .collect() - } - _ => Vec::new(), - } + let scope = tab.current_parametric_scope(); + tab.scene + .constraint_glyph_placements_screen( + scope, + sel_ref.vp_size, + self.show_constraint_values, + self.constraint_bar_display, + ) + .into_iter() + .map(|(id, point, direction, label, is_conflicting, hover_points)| { + let selected = tab.scene.selected_constraint == Some(id); + ( + point, + direction, + label, + is_conflicting, + selected, + hover_points, + ) + }) + .collect() }; crate::ui::overlay::selection_overlay( std::sync::Arc::clone(&tab.scene.selection), @@ -976,38 +937,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 +1003,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 +1200,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 +1401,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, - )); + // 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, + )); + } } - } - // 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, + // 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, + tab.scene.selected_constraint, + 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 +1812,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 +1852,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 +1941,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 +1952,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 +1966,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 +1982,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 +2002,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 +2148,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 +2191,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 +2205,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) @@ -2286,15 +2218,16 @@ bg={bg_ms:.1}ms n={view_count}" &history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack), self.win_size, self.tabs[self.active_tab].is_start, + &self.recent_colors, ) .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 +2262,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 +2321,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 +2337,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 +2352,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 +2480,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 +2655,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 +2682,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 +2833,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 +2907,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 +2967,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 +3075,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 +3111,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 +3222,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 +3249,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 +3283,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 +3355,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 +3375,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 +3440,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 +3484,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 +3536,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 +3585,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 +3621,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 +3641,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 +3660,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 +3721,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 +3784,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 +3807,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 +3858,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 +3937,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 +3960,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 +3973,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 +4024,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 +4040,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 +4057,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 +4071,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 +4087,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 +4107,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 +4143,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..8df1e0324 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -25,6 +25,7 @@ impl OpenCADStudio { Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), + Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::Plot) => crate::tr!("modal", "plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -201,7 +202,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, @@ -292,8 +292,8 @@ impl OpenCADStudio { let confirm = self.drafting_settings_close_confirm; sized_flow( ex, - 700, - 480, + 780, + 500, |flow| { if let Some(state) = state { crate::ui::window::drafting_settings::view_window( @@ -308,6 +308,20 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::AutoConstrainSettings => sized_flow( + ex, + 620, + 610, + |flow| { + crate::ui::window::auto_constrain_settings::view_window( + &self.auto_constrain_settings, + self.auto_constrain_selected_row, + &self.auto_constrain_distance_input, + &self.auto_constrain_angle_input, + flow, + ) + }, + ), super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 3192b9128..77c57b46c 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1114,6 +1114,7 @@ pub(super) fn viewport_context_menu_overlay( bottom_inset: f32, has_cmd: bool, has_selection: bool, + selected_constraint: Option, isolation_active: bool, last_cmds: Vec, draworder_open: bool, @@ -1250,6 +1251,15 @@ pub(super) fn viewport_context_menu_overlay( t!("Invert Selection").into_owned(), Message::InvertSelection, )); + } else if let Some(id) = selected_constraint { + // A selected constraint-glyph pill gets its own minimal menu — + // none of the entity-selection actions above (Move/Copy/Isolate/ + // Select Similar) apply to it. + items.push(item( + t!("Delete").into_owned(), + Message::PropConstraintDelete(id), + )); + items.push(sep()); } if isolation_active { items.push(item( diff --git a/src/command.rs b/src/command.rs index 56e9d83c9..a8a18632b 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); @@ -1233,6 +1237,17 @@ pub enum ExtrudeExtent { Path(Handle), } +/// One ordered selection used by the Coincident command. A click can name +/// either a constraint point on an entity or the entity's whole curve. Typed +/// coordinates have no entity handle and are resolved by the host after the +/// command returns. +#[derive(Clone, Copy, Debug)] +pub struct CoincidentPick { + pub handle: Option, + pub point: DVec3, + pub whole_curve: bool, +} + /// Construction options shared by SWEEP creation and its live preview. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SweepOptions { @@ -1405,7 +1420,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 +1448,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:: @@ -1456,19 +1477,28 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, - /// Adds a Coincident constraint between the sub-entity points nearest - /// the two picked positions. The host resolves the raw points because - /// `CadCommand` has no document access. + /// Opens the Auto Constrain settings dialog from the selection prompt. + OpenAutoConstrainSettings, + /// Adds an ordered Coincident relation. Point/point selections create a + /// Coincident constraint; point/curve selections create the corresponding + /// point-on-curve relation while retaining Coincident command semantics. AddCoincidentConstraint { - point_a: DVec3, - point_b: DVec3, + first: CoincidentPick, + second: CoincidentPick, + /// Keep the first curve active and accept another point. + multiple: bool, label: &'static str, }, + /// Apply only Coincident relations that already exist geometrically in + /// the selected set. + AddAutoCoincidentConstraints { + handles: Vec, + }, /// Add a persistent `CenterPoint`/`Midpoint`/`PointOnCurve` constraint /// (`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 +1507,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 +1546,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 +1572,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 +1597,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 +1616,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 +1661,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 +1691,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 +1702,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,17 +1857,18 @@ 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 /// as a single unit. The command stays active. Used by PLINE so the partial /// polyline is a real, snappable entity while later vertices are placed. CommitLiveEntity(EntityType), - /// Replace the geometry of the live entity `handle` in place — preserving - /// its layer — without pushing a new undo snapshot. When `finish` is true - /// the command also exits (the entity is already committed, so no separate - /// commit is needed). + /// Replace the geometry of the live entity `handle` in place while + /// preserving its document identity and common display properties. No new + /// undo snapshot is pushed. When `finish` is true the command also exits. UpdateLiveEntity { handle: Handle, entity: EntityType, @@ -1808,7 +1882,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 +2124,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 +2135,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 +2212,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) {} @@ -2211,7 +2294,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() } @@ -2425,7 +2511,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) {} @@ -2433,9 +2521,7 @@ pub trait CadCommand: Send { None } - fn hatch_preview_models( - &self, - ) -> Option> { + fn hatch_preview_models(&self) -> Option> { None } @@ -2622,6 +2708,8 @@ mod constraint_registry_tests { let names = all_registered_command_names(); for id in [ "CCONSTRAINT", + "GCCOINCIDENT", + "GEOMCONSTRAINT", "EDCONSTRAINT", "CPCONSTRAINT", "MPCONSTRAINT", @@ -2640,7 +2728,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/entities/dimension.rs b/src/entities/dimension.rs index d455cbde5..572f09dca 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3505,6 +3505,26 @@ fn tessellate_dimension_inner( return wires; } } + if let Some((local, normal)) = linear_dimension_in_ocs(dim) { + let mut wires = tessellate_dimension_inner( + document, + handle, + &local, + selected, + entity_color, + line_weight_px, + anno_scale, + selected_set, + active_viewport, + bg_color, + None, + world_per_pixel, + ); + for wire in &mut wires { + map_wire_ocs_to_wcs(wire, normal); + } + return wires; + } let name = handle.value().to_string(); // (Baked-block fast path moved up into scene::tessellate_entity so the // recursive call goes through the LOD ladder, not the kernel path.) @@ -5300,6 +5320,50 @@ pub(crate) fn large_radial_dimension_in_ocs( Some((local, normal)) } +/// A linear or aligned dimension in a plane off world Z, moved into its own +/// OCS. Their builders read the offset and extension directions in XY, so +/// they draw it there and the result is mapped back onto the plane. `None` +/// for a dimension in world XY or of another kind. +pub(crate) fn linear_dimension_in_ocs(dimension: &Dimension) -> Option<(Dimension, Vector3)> { + let length = dimension.base().normal.length(); + if !length.is_finite() || length <= 1.0e-12 { + return None; + } + let normal = dimension.base().normal / length; + if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 { + return None; + } + let normal_tuple = (normal.x, normal.y, normal.z); + let to_ocs = |point: Vector3| { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + normal_tuple, + ); + Vector3::new(point.0, point.1, point.2) + }; + let mut local = dimension.clone(); + match &mut local { + Dimension::Linear(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + Dimension::Aligned(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + _ => return None, + } + Some((local, normal)) +} + fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { let normal_tuple = (normal.x, normal.y, normal.z); let map = |x: f64, y: f64, z: f64| { @@ -7768,4 +7832,79 @@ mod linear_transform_tests { ); assert_measures_ten(&dim); } + + /// Every stroke point the dimension draws, with its low residual added. + fn stroke_points(dim: &Dimension) -> Vec { + let document = CadDocument::new(); + let wires = dim.tessellate( + &document, + Handle::new(0x40), + false, + [1.0; 4], + 1.0, + 1.0, + &rustc_hash::FxHashSet::default(), + None, + [0.0, 0.0, 0.0, 1.0], + None, + None, + ); + let mut points = Vec::new(); + for wire in &wires { + for (index, high) in wire.points.iter().enumerate() { + if !high[0].is_finite() { + continue; + } + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + points.push(DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + )); + } + } + points + } + + /// Turned 90° about X into the XZ plane, the dimension has to draw the + /// same strokes turned with it. + fn assert_draws_turned_with_its_plane(flat: Dimension) { + let turn = glam::DQuat::from_axis_angle(DVec3::X, FRAC_PI_2); + let expected: Vec = stroke_points(&flat).into_iter().map(|p| turn * p).collect(); + assert!(!expected.is_empty(), "the dimension draws no strokes"); + let mut turned = flat; + apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: FRAC_PI_2, + }, + ); + let drawn = stroke_points(&turned); + assert_eq!(drawn.len(), expected.len(), "stroke point count"); + for (index, (got, want)) in drawn.iter().zip(&expected).enumerate() { + assert!( + got.abs_diff_eq(*want, 1e-3), + "point {index}: expected {want:?}, got {got:?}" + ); + } + } + + /// A linear dimension in a working plane off world Z draws in that plane. + /// Its offset used to be read in world XY only, so once it ran along Z the + /// dimension line fell onto the measured points and the extension lines + /// stuck out along world Y. + #[test] + fn a_linear_dimension_off_world_z_draws_in_its_own_plane() { + assert_draws_turned_with_its_plane(horizontal()); + } + + /// An aligned dimension draws through the same builder and plane. + #[test] + fn an_aligned_dimension_off_world_z_draws_in_its_own_plane() { + let mut d = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(6.0, 8.0, 0.0)); + d.definition_point = Vector3::new(2.0, 11.0, 0.0); + assert_draws_turned_with_its_plane(Dimension::Aligned(d)); + } } diff --git a/src/entities/line.rs b/src/entities/line.rs index 0905194d8..0f4e9fdfb 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -214,6 +214,9 @@ fn properties(line: &Line) -> Vec { ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")), ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")), + edit(t!("Normal X").as_ref(), "normal_x", line.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z), ], }] } @@ -286,6 +289,19 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { "end_x" => line.end.x = v, "end_y" => line.end.y = v, "end_z" => line.end.z = v, + "normal_x" | "normal_y" | "normal_z" => { + let mut normal = glam::DVec3::new(line.normal.x, line.normal.y, line.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => unreachable!(), + } + if normal.is_finite() && normal.length_squared() > 1.0e-18 { + let normal = normal.normalize(); + line.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + } + } _ => {} } } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 5dc8161e1..840555a8e 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -13,6 +13,7 @@ use crate::scene::model::hatch_model::HatchModel; #[cfg(not(target_arch = "wasm32"))] use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; +use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] use printpdf::{ BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, @@ -37,25 +38,18 @@ impl std::ops::Deref for PlotWire { } } +/// Decoded image geometry plus inherited block/viewport clip boundaries. +#[derive(Clone, Debug)] +pub struct PlotImage { + pub image: ImageModel, + pub clips: Vec>, +} + // The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via // lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web // build gets these stubs so the call sites still compile. #[cfg(target_arch = "wasm32")] -pub fn export_pdf( - _wires: &[PlotWire], - _hatches: &[HatchModel], - _wipeouts: &[HatchModel], - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _path: &Path, - _plot_style: Option<&PlotStyleTable>, - _options: PdfPlotOptions, -) -> Result<(), String> { +pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> { Err("PDF export is not available in the web version.".into()) } @@ -94,7 +88,6 @@ pub struct PdfPlotOptions { pub transparency: bool, pub stamp: bool, pub merge_lines: bool, - pub group_splits: PlotGroupSplits, } /// End indexes of the first paper/model render group in each flat input list. @@ -103,16 +96,26 @@ pub struct PlotGroupSplits { pub wires: usize, pub hatches: usize, pub wipeouts: usize, + pub images: usize, } -/// Owned render data for one page in a multi-page PDF. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -pub struct PdfPageInput { +#[derive(Default)] +pub struct PlotContent { pub wires: std::sync::Arc>, pub hatches: Vec, pub wipeouts: Vec, + pub images: Vec, + pub group_splits: PlotGroupSplits, +} + +/// Owned geometry and settings for one PDF page. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct PdfPageInput { + pub content: PlotContent, + /// Page dimensions in mm, after any 90/270-degree rotation. pub paper_w: f64, pub paper_h: f64, + /// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates. pub offset_x: f64, pub offset_y: f64, pub rotation_deg: i32, @@ -130,50 +133,16 @@ impl Default for PdfPlotOptions { transparency: false, stamp: false, merge_lines: false, - group_splits: PlotGroupSplits::default(), } } } // ── Public entry point ──────────────────────────────────────────────────── -/// Export `wires` to a PDF file. -/// -/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller). -/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing -/// origin maps to the bottom-left corner of the page. -/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page. +/// Export one page to a PDF file. #[cfg(not(target_arch = "wasm32"))] -pub fn export_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - path: &Path, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Result<(), String> { - let bytes = build_pdf( - wires, - hatches, - wipeouts, - paper_w as f32, - paper_h as f32, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - write_pdf_atomically(path, &bytes) +pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> { + export_pdf_pages(std::slice::from_ref(page), path, None) } /// Export several independently sized pages into one PDF file. @@ -186,7 +155,7 @@ pub fn export_pdf_pages( if pages.is_empty() { return Err("No pages were selected.".into()); } - let bytes = build_pdf_pages(pages, plot_style); + let bytes = build_pdf_pages(pages, plot_style)?; write_pdf_atomically(path, &bytes) } @@ -205,6 +174,202 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { Ok(()) } +#[cfg(not(target_arch = "wasm32"))] +fn image_clip(ops: &mut Vec, rings: Vec>) { + ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: rings + .into_iter() + .map(|ring| PolygonRing { + points: ring + .into_iter() + .map(|[x, y]| LinePoint { + p: Point { x: Pt(x), y: Pt(y) }, + bezier: false, + }) + .collect(), + }) + .collect(), + mode: PaintMode::Clip, + winding_order: WindingOrder::EvenOdd, + }, + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit_image( + doc: &mut PdfDocument, + resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + ops: &mut Vec, + plot: &PlotImage, + ox: f64, + oy: f64, + options: PdfPlotOptions, +) -> Result<(), String> { + let image = &plot.image; + let expected_byte_count = (image.width as usize) + .checked_mul(image.height as usize) + .and_then(|n| n.checked_mul(4)); + if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) { + return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into()); + } + if image.verts.len() < 3 || image.verts.len() % 3 != 0 { + return Err("Cannot plot bitmap: incomplete triangle geometry.".into()); + } + if options.transparency && image.opacity <= 0.0 { + return Ok(()); + } + let to_page = |high: [f32; 3], low: [f32; 3]| { + [ + ((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32, + ((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32, + ] + }; + let corners: [[f32; 2]; 4] = + std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i])); + if !corners.iter().flatten().all(|v| v.is_finite()) + || !image.verts.iter().all(|v| { + to_page(v.pos, v.pos_low) + .iter() + .chain(&v.uv) + .all(|c| c.is_finite()) + }) + || !plot + .clips + .iter() + .all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite())) + || !image.opacity.is_finite() + { + return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into()); + } + let key = ( + std::sync::Arc::as_ptr(&image.pixels) as usize, + image.width, + image.height, + ); + let id = resources + .entry(key) + .or_insert_with(|| { + let id = printpdf::XObjectId::new(); + // add_image clones its argument; the public map accepts owned pixels. + doc.resources.xobjects.map.insert( + id.clone(), + printpdf::XObject::Image(printpdf::RawImage { + pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()), + width: image.width as usize, + height: image.height as usize, + data_format: printpdf::RawImageFormat::RGBA8, + tag: Vec::new(), + }), + ); + id + }) + .clone(); + ops.push(Op::SaveGraphicsState); + if options.transparency && image.opacity < 1.0 { + // printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside + // this image's saved state so the nonstroking alpha is correct. + let alpha = image.opacity.clamp(0.0, 1.0); + let gs = doc.add_graphics_state( + ExtendedGraphicsState::default() + .with_current_fill_alpha(alpha) + .with_current_stroke_alpha(alpha), + ); + ops.push(Op::LoadGraphicsState { gs }); + } + for clip in &plot.clips { + image_clip( + ops, + vec![clip + .iter() + .map(|p| { + [ + ((p[0] + ox) * MM_TO_PT as f64) as f32, + ((p[1] + oy) * MM_TO_PT as f64) as f32, + ] + }) + .collect()], + ); + } + let draw = |ops: &mut Vec, matrix: [f32; 6]| { + ops.push(Op::SetTransformationMatrix { + matrix: printpdf::CurTransMat::Raw(matrix), + }); + ops.push(Op::UseXobject { + id: id.clone(), + // Cancel printpdf's pixel-size transform: matrix maps the PDF + // image's unit square directly onto the CAD quad in page points. + transform: printpdf::XObjectTransform { + dpi: Some(72.0), + scale_x: Some(1.0 / image.width as f32), + scale_y: Some(1.0 / image.height as f32), + ..Default::default() + }, + }); + }; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let affine = (0..2).all(|axis| { + (corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs() + <= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0) + }); + if affine { + // A single draw preserves intrinsic IMAGE clipping without overlapping image draws. + image_clip( + ops, + image + .verts + .chunks_exact(3) + .map(|tri| { + tri.iter() + .map(|vertex| to_page(vertex.pos, vertex.pos_low)) + .collect() + }) + .collect(), + ); + draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]); + } else { + // PDF transforms are affine; perspective sampling is approximate. + for triangle in image.verts.chunks_exact(3) { + let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low)); + let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]); + let matrix = image_triangle_matrix(page, uv) + .filter(|matrix| matrix.iter().all(|v| v.is_finite())) + .ok_or("Cannot plot bitmap: degenerate texture mapping.")?; + ops.push(Op::SaveGraphicsState); + image_clip(ops, vec![page.to_vec()]); + draw(ops, matrix); + ops.push(Op::RestoreGraphicsState); + } + } + ops.push(Op::RestoreGraphicsState); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> { + let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]]; + let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]]; + let determinant = u[0] * v[1] - v[0] * u[1]; + if determinant.abs() < 1e-12 { + return None; + } + let axes: [[f32; 2]; 2] = std::array::from_fn(|i| { + [ + ((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant, + (u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant, + ] + }); + Some([ + axes[0][0], + axes[1][0], + axes[0][1], + axes[1][1], + p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1], + p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1], + ]) +} + /// Show a parented PDF save-file dialog and return the chosen path. /// /// The parent comes from `iced::window::run`, keeping the portal request tied @@ -230,83 +395,32 @@ pub fn pick_pdf_path_owned( // ── PDF builder ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -fn build_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - // Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6 - // where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself - // already quantised before it can cancel the coordinate it is meant to cancel. - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Vec { +fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result, String> { let mut doc = PdfDocument::new("Open CAD Studio Export"); - append_pdf_page( - &mut doc, - wires, - hatches, - wipeouts, - paper_w, - paper_h, - ox, - oy, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) -} - -#[cfg(not(target_arch = "wasm32"))] -fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Vec { - let mut doc = PdfDocument::new("Open CAD Studio Export"); - for page in pages { - append_pdf_page( - &mut doc, - &page.wires, - &page.hatches, - &page.wipeouts, - page.paper_w as f32, - page.paper_h as f32, - page.offset_x, - page.offset_y, - page.rotation_deg, - page.scale, - page.clip, - page.plot_style.as_ref().or(plot_style), - page.options, - ); + // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. + // Allocation addresses cannot be reused by another source during this export. + let mut image_resources = std::collections::HashMap::new(); + for (index, page) in pages.iter().enumerate() { + append_pdf_page(&mut doc, &mut image_resources, page, plot_style) + .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) + Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) } #[cfg(not(target_arch = "wasm32"))] fn append_pdf_page( doc: &mut PdfDocument, - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) { + image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + page: &PdfPageInput, + fallback_plot_style: Option<&PlotStyleTable>, +) -> Result<(), String> { + let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content; + let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32); + let (ox, oy) = (page.offset_x, page.offset_y); + let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip); + let plot_style = page.plot_style.as_ref().or(fallback_plot_style); + let options = page.options; let mut ops: Vec = Vec::new(); // White page background. @@ -411,23 +525,26 @@ fn append_pdf_page( let (first_wires, second_wires) = - wires.split_at(options.group_splits.wires.min(wires.len())); + wires.split_at(group_splits.wires.min(wires.len())); let (first_hatches, second_hatches) = - hatches.split_at(options.group_splits.hatches.min(hatches.len())); + hatches.split_at(group_splits.hatches.min(hatches.len())); let (first_wipeouts, second_wipeouts) = - wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len())); - for (wires, hatches, wipeouts) in [ - (first_wires, first_hatches, first_wipeouts), - (second_wires, second_hatches, second_wipeouts), + wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len())); + let (first_images, second_images) = + images.split_at(group_splits.images.min(images.len())); + for (wires, hatches, wipeouts, images) in [ + (first_wires, first_hatches, first_wipeouts, first_images), + (second_wires, second_hatches, second_wipeouts, second_images), ] { enum DrawItem<'a> { WireFill(&'a PlotWire), Hatch(&'a HatchModel), + Image(&'a PlotImage), Wire(&'a PlotWire), Text(&'a PlotWire), } - let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len()); + let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len()); let mut sequence = 0usize; for wire in wires { if !wire.fill_tris.is_empty() { @@ -445,6 +562,10 @@ fn append_pdf_page( draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch))); sequence += 1; } + for image in images { + draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image))); + sequence += 1; + } draw_items.sort_by(|a, b| { a.0.total_cmp(&b.0) .then_with(|| a.1.cmp(&b.1)) @@ -508,6 +629,10 @@ fn append_pdf_page( last_dash = None; continue; } + DrawItem::Image(image) => { + emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?; + continue; + } DrawItem::Wire(wire) => wire, }; let [mut r, mut g, mut b, a] = wire.color; @@ -722,6 +847,7 @@ fn append_pdf_page( let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops); doc.pages.push(page); + Ok(()) } /// Build a PDF dash array (in points) from a WireModel linetype pattern. @@ -1517,6 +1643,21 @@ fn emit_text( mod tests { use super::*; + fn test_page(wires: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } + } + #[test] fn atomic_write_replaces_an_existing_pdf() { let path = std::env::temp_dir().join(format!( @@ -1546,20 +1687,10 @@ mod tests { ), draw_depth: 0.0, }; - let bytes = build_pdf( - &[w], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 2.0, - Some((10.0, 10.0, 100.0, 100.0)), - None, - PdfPlotOptions::default(), - ); + let mut page = test_page(vec![w]); + page.scale = 2.0; + page.clip = Some((10.0, 10.0, 100.0, 100.0)); + let bytes = build_pdf_pages(&[page], None).unwrap(); // A valid PDF is produced (starts with the PDF header) and is non-trivial. assert!(bytes.starts_with(b"%PDF"), "not a PDF"); assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len()); @@ -1594,34 +1725,8 @@ mod tests { let mut blank = wire.clone(); blank.wire.text_verts.clear(); - let with_text = build_pdf( - &[wire], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); - let no_text = build_pdf( - &[blank], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); + let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap(); assert!(with_text.starts_with(b"%PDF")); assert!( with_text.len() > no_text.len(), diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index becceca44..89b2033c6 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -357,13 +357,8 @@ impl PlotStyleTable { let description = self.description.replace(['\r', '\n'], " "); s.push_str(&format!("description=\"{description}\n")); s.push_str("aci_table_available=TRUE\n"); - // The factor scales every lineweight when applied, so write it in full, - // keeping the decimal point on a whole value. - let mut scale_factor = self.scale_factor.to_string(); - if scale_factor.chars().all(|c| c.is_ascii_digit() || c == '-') { - scale_factor.push_str(".0"); - } - s.push_str(&format!("scale_factor={scale_factor}\n")); + // The factor scales every lineweight when applied, so write it in full. + s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor))); s.push_str(&format!( "apply_factor={}\n", if self.apply_factor { "TRUE" } else { "FALSE" } @@ -420,7 +415,7 @@ impl PlotStyleTable { } s.push_str("}\ncustom_lineweight_table{\n"); for (index, weight) in self.lineweights.iter().enumerate() { - s.push_str(&format!(" {index}={weight:.2}\n")); + s.push_str(&format!(" {index}={}\n", full_real(*weight))); } s.push_str("}\n"); s @@ -512,6 +507,15 @@ fn adler32(bytes: &[u8]) -> u32 { (b << 16) | a } +/// A real number written in full, keeping the decimal point on a whole value. +fn full_real(value: f32) -> String { + let mut text = value.to_string(); + if text.chars().all(|c| c.is_ascii_digit() || c == '-') { + text.push_str(".0"); + } + text +} + fn packed_rgb([r, g, b]: [u8; 3]) -> i32 { u32::from_be_bytes([0xC2, r, g, b]) as i32 } @@ -780,4 +784,16 @@ mod round_trip_tests { assert!(table.to_text().contains("\nscale_factor=1.0\n")); assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0); } + + /// Saving keeps each custom lineweight as the table holds it. They used to + /// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one. + #[test] + fn custom_lineweights_survive_a_save() { + let mut table = PlotStyleTable::identity("pens.ctb"); + table.lineweights[1] = 0.035; + table.aci_entries[1].lineweight = 1; + let reloaded = saved_and_reloaded(&table); + assert_eq!(reloaded.lineweights, table.lineweights); + assert_eq!(reloaded.resolve_lineweight(1), Some(0.035)); + } } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..35129dd61 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -9,9 +9,6 @@ #[cfg(not(target_arch = "wasm32"))] use crate::io::pdf_export; -use crate::io::plot_style::PlotStyleTable; -use crate::scene::model::hatch_model::HatchModel; -use crate::io::pdf_export::PlotWire; #[cfg(not(target_arch = "wasm32"))] pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { @@ -50,8 +47,6 @@ pub struct PrintOptions { /// setting wins, so the field is legitimately unread there. #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, - /// Controls applied while building the intermediate PDF. - pub render: crate::io::pdf_export::PdfPlotOptions, } #[cfg(target_arch = "wasm32")] @@ -60,19 +55,8 @@ pub fn list_printers() -> Vec { } #[cfg(target_arch = "wasm32")] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - _wires: std::sync::Arc>, - _hatches: Vec, - _wipeouts: Vec, - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _plot_style: Option, + _page: crate::io::pdf_export::PdfPageInput, _opts: PrintOptions, ) -> Result { Err("Printing is not available in the web version.".into()) @@ -89,8 +73,7 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu } /// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; -/// Windows returns an empty list (the "printto" dispatch targets a named -/// printer directly and the system default is always available). +/// Windows queries the spooler's cached local and connected printer list. #[cfg(not(target_arch = "wasm32"))] pub fn list_printers() -> Vec { #[cfg(not(target_os = "windows"))] @@ -111,7 +94,140 @@ pub fn list_printers() -> Vec { } #[cfg(target_os = "windows")] { - Vec::new() + windows_printers().unwrap_or_else(|error| { + eprintln!("Could not enumerate printers: {error}"); + Vec::new() + }) + } +} + +#[cfg(target_os = "windows")] +fn windows_printers() -> std::io::Result> { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::{ + EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + }; + + let mut bytes_needed = 0; + let mut printer_count = 0; + let mut buffer = Vec::::new(); + // The printer list can grow between the size query and the data query. + for _ in 0..4 { + let buffer_bytes = u32::try_from(std::mem::size_of_val(buffer.as_slice())) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?; + let data = if buffer.is_empty() { + std::ptr::null_mut() + } else { + buffer.as_mut_ptr().cast::() + }; + // SAFETY: data is null for the size query, otherwise it points to an + // aligned, writable allocation of buffer_bytes bytes. + let success = unsafe { + EnumPrintersW( + PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + std::ptr::null(), + 4, + data, + buffer_bytes, + &mut bytes_needed, + &mut printer_count, + ) + }; + if success != 0 { + return windows_printer_names(&buffer, printer_count as usize); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes { + return Err(std::io::Error::from_raw_os_error(error as i32)); + } + buffer.resize( + (bytes_needed as usize).div_ceil(std::mem::size_of::()), + 0, + ); + } + Err(std::io::Error::from_raw_os_error( + ERROR_INSUFFICIENT_BUFFER as i32, + )) +} + +#[cfg(target_os = "windows")] +fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + const { + assert!(std::mem::align_of::() >= std::mem::align_of::()); + } + let buffer_bytes = std::mem::size_of_val(buffer); + if printer_count > buffer_bytes / std::mem::size_of::() { + return Err(Error::from(ErrorKind::InvalidData)); + } + // SAFETY: the initialized buffer is aligned and large enough for these + // records. Raw pointer fields are validated before reading any names. + let records = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().cast::(), printer_count) + }; + let mut names = Vec::with_capacity(printer_count); + for record in records { + if record.pPrinterName.is_null() { + continue; + } + let offset = (record.pPrinterName as usize) + .checked_sub(buffer.as_ptr() as usize) + .filter(|offset| *offset < buffer_bytes && offset % 2 == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + // SAFETY: offset is UTF-16 aligned and the slice ends within buffer. + let utf16 = unsafe { + std::slice::from_raw_parts( + buffer.as_ptr().cast::().add(offset).cast::(), + (buffer_bytes - offset) / 2, + ) + }; + let length = utf16 + .iter() + .position(|&unit| unit == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + if length != 0 { + names.push(String::from_utf16_lossy(&utf16[..length])); + } + } + names.sort(); + names.dedup(); + Ok(names) +} + +#[cfg(all(test, target_os = "windows"))] +mod printer_buffer_tests { + use super::windows_printer_names; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + #[test] + fn printer_names_stay_within_the_returned_buffer() { + assert!(windows_printer_names(&[], 0).unwrap().is_empty()); + assert!(windows_printer_names(&[], 1).is_err()); + let mut buffer = vec![0usize; 16]; + let name_offset = std::mem::size_of::(); + let name: Vec = "Printer \u{03b1}\0".encode_utf16().collect(); + // SAFETY: the aligned allocation holds the record and UTF-16 name. + unsafe { + let record = buffer.as_mut_ptr().cast::(); + let name_ptr = buffer + .as_mut_ptr() + .cast::() + .add(name_offset) + .cast::(); + std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len()); + (*record).pPrinterName = name_ptr; + assert_eq!( + windows_printer_names(&buffer, 1).unwrap(), + ["Printer \u{03b1}"] + ); + (*record).pPrinterName = buffer.as_mut_ptr().cast::().wrapping_sub(1); + assert!(windows_printer_names(&buffer, 1).is_err()); + (*record).pPrinterName = name_ptr; + } + buffer[name_offset / std::mem::size_of::()..].fill(usize::MAX); + assert!(windows_printer_names(&buffer, 1).is_err()); } } @@ -172,46 +288,18 @@ pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> { Err("Printer properties are not available in the web version.".into()) } -/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, -/// grayscale, quality, DPI). +/// Render a page and send it to the selected printer. #[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - wires: std::sync::Arc>, - hatches: Vec, - wipeouts: Vec, - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option, + page: crate::io::pdf_export::PdfPageInput, opts: PrintOptions, ) -> Result { let tmp_path = temp_pdf_path("print"); - pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - &tmp_path, - plot_style.as_ref(), - opts.render, - )?; + pdf_export::export_pdf(&page, &tmp_path)?; dispatch_to_printer_opts(&tmp_path, &opts) } -/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for -/// clipped window plots, whose PDF is built with a scale + clip the plain -/// `print_wires_with` path doesn't expose. +/// Send an already-rendered PDF to the selected printer. #[cfg(not(target_arch = "wasm32"))] pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result { dispatch_to_printer_opts(path, opts) diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 6ff2016ae..c9e783fc2 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -42,7 +42,7 @@ impl CadModule for AnnotateModule { } fn ribbon_groups(&self) -> &[RibbonGroup] { - use crate::modules::draw::draw::{revcloud, wipeout}; + use crate::modules::draw::draw::{centerline, dimcenter, revcloud, wipeout}; static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); GROUPS.get_or_init(|| { @@ -148,6 +148,44 @@ impl CadModule for AnnotateModule { }, ], }, + // ── Centerlines ─────────────────────────────────────────────── + RibbonGroup { + title: "Centerlines", + tools: vec![ + RibbonItem::LargeTool(dimcenter::tool()), + RibbonItem::LargeTool(centerline::tool()), + RibbonItem::Dropdown { + id: "ANNOTATE_CENTER_ASSOCIATIVITY", + icon: crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + items: vec![ + ( + "CENTERREASSOCIATE", + "Reassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERDISASSOCIATE", + "Disassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERRESET", + "Reset Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ], + default: "CENTERREASSOCIATE", + }, + ], + }, // ── Leaders ────────────────────────────────────────────────── RibbonGroup { title: "Leaders", diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index 118dc8a87..c28454aa8 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -304,6 +304,14 @@ impl AlignCommand { } inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand +// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: one-shot +// bounding-box alignment, dispatched directly (no CadCommand of their own) by +// `OpenCADStudio::align_selected_bounds` in `src/app/commands/inquiry.rs`. +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "ALIGNLEFT", "ALIGNHCENTER", "ALIGNRIGHT", "ALIGNTOP", "ALIGNVCENTER", "ALIGNBOTTOM", + ], +}); #[cfg(test)] mod tests { diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 52606f06e..5243e8af2 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { .collect(); } } + if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) { + let (x_axis, y_axis) = + crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + let plane = WorkingPlane::new( + DVec3::ZERO, + DVec3::new(x_axis.0, x_axis.1, x_axis.2), + DVec3::new(y_axis.0, y_axis.1, y_axis.2), + ); + return explode_dimension(&local, doc) + .into_iter() + .map(|entity| plane.place_entity(entity)) + .collect(); + } let base = dim.base(); let met = dim_metrics(dim, doc); @@ -1659,6 +1672,58 @@ mod tests { assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); } + /// The line segments EXPLODE turns the dimension into. + fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> { + let point = |v: Vector3| DVec3::new(v.x, v.y, v.z); + explode_dimension(dim, doc) + .into_iter() + .filter_map(|entity| match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + _ => None, + }) + .collect() + } + + /// EXPLODE of a linear dimension in a working plane off world Z leaves + /// its lines in that plane. Turned 90° about X, the dimension has to + /// explode into the same lines turned with it; its offset, now along Z, + /// used to be read in world XY only. + #[test] + fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() { + use crate::command::EntityTransform; + let doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + let flat = Dimension::Linear(d); + let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2); + let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc) + .into_iter() + .map(|(a, b)| (turn * a, turn * b)) + .collect(); + assert!(!expected.is_empty(), "the dimension explodes into no lines"); + + let mut turned = EntityType::Dimension(flat); + crate::scene::view::dispatch::apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: std::f64::consts::FRAC_PI_2, + }, + ); + let EntityType::Dimension(turned) = turned else { + panic!("dimension expected"); + }; + let lines = exploded_segments(&turned, &doc); + assert_eq!(lines.len(), expected.len(), "line count"); + for (index, (got, want)) in lines.iter().zip(&expected).enumerate() { + assert!( + got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9), + "line {index}: expected {want:?}, got {got:?}" + ); + } + } + // An angular dimension must bake its swept ARC (not just two rays), else a // saved+reloaded angular dim collapses to a V. The block should carry the // two extension lines plus many arc chords. diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 8116fbc07..d695ce6d7 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -34,6 +34,8 @@ pub struct SelectObjectsCommand { /// Whether the polygon being picked takes what it merely touches. Ignored /// by a fence, which has no inside to speak of. pick_crossing: bool, + /// Add the command-specific Settings keyword used by Auto Constrain. + auto_constrain_settings: bool, } impl SelectObjectsCommand { @@ -47,6 +49,7 @@ impl SelectObjectsCommand { show_options: true, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -61,6 +64,7 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -75,8 +79,15 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } + + pub fn auto_constrain(pending_cmd: &str) -> Self { + let mut command = Self::new(pending_cmd); + command.auto_constrain_settings = true; + command + } } impl CadCommand for SelectObjectsCommand { @@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand { .into_owned(); } if self.commit_on_enter && !self.handles.is_empty() { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):", + cmd = self.prompt_cmd, + count = self.handles.len() + ) + .into_owned(); + } t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", cmd = self.prompt_cmd, @@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand { ) .into_owned() } else { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings]:", + cmd = self.prompt_cmd + ) + .into_owned(); + } t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand { // the on-screen keyboard-less case needs. fn options(&self) -> Vec { if self.commit_on_enter && self.show_options { - vec![ + if self.auto_constrain_settings { + return vec![CmdOption::new(t!("Settings").as_ref(), "S")]; + } + let options = vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), CmdOption::new(t!("Fence").as_ref(), "F"), @@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand { CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Last").as_ref(), "L"), - ] + ]; + options } else { Vec::new() } @@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand { return None; } match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if self.auto_constrain_settings => { + Some(CmdResult::OpenAutoConstrainSettings) + } "F" | "FENCE" => { self.pick = Some(FencePick::fence()); Some(CmdResult::NeedPoint) diff --git a/src/modules/parametric/coincident.rs b/src/modules/parametric/coincident.rs index eccad85ba..0a13b7329 100644 --- a/src/modules/parametric/coincident.rs +++ b/src/modules/parametric/coincident.rs @@ -1,20 +1,10 @@ -//! CCONSTRAINT — manual Coincident constraint input. -//! unlike the rest of this module's plain select-and-click constraints, -//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc -//! center), not the whole entity, so it needs two point picks instead of a -//! selection. -//! -//! A `CadCommand` has no document access (see `value.rs`'s module doc -//! 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`) -//! and adds the constraint, the same split `ReassociateCenterMark` already -//! uses for a similar reason. +//! Interactive Coincident input: ordered point/curve picks, repeated +//! point-to-curve placement, and Coincident-only automatic inference. +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; pub mod coincident_tool { @@ -31,45 +21,272 @@ pub mod coincident_tool { } } -#[derive(Default)] +#[derive(Clone)] +enum Step { + First, + FirstObject, + Second(CoincidentPick), + PointsOnCurve { + curve: CoincidentPick, + multiple: bool, + }, + AutoConstrain { + handles: Vec, + }, +} + pub struct CoincidentConstraintCommand { - first: Option, + step: Step, + picked_entity: Option, } impl CoincidentConstraintCommand { pub fn new() -> Self { - Self::default() + Self { + step: Step::First, + picked_entity: None, + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn curve_pick(handle: Handle, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle: Some(handle), + point, + whole_curve: true, + } + } + + fn picked_constraint_point(entity: &EntityType, point: DVec3) -> bool { + crate::scene::parametric_constraints::is_parametric_point_near( + entity, + acadrust::types::Vector3::new(point.x, point.y, point.z), + ) + } + + fn valid_curve(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Line(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline2D(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + ) } } impl CadCommand for CoincidentConstraintCommand { fn name(&self) -> &'static str { - "CCONSTRAINT" + "GCCOINCIDENT" } fn prompt(&self) -> String { - if self.first.is_none() { - "COINCIDENT Specify first point:".to_string() - } else { - "COINCIDENT Specify second point:".to_string() + match self.step.clone() { + Step::First => { + "COINCIDENT Select first point or [Object/Autoconstrain] :".to_string() + } + Step::FirstObject => "COINCIDENT Select object:".to_string(), + Step::Second(_) => "COINCIDENT Select second point or object:".to_string(), + Step::PointsOnCurve { multiple: false, .. } => { + "COINCIDENT Select point or [Multiple]:".to_string() + } + Step::PointsOnCurve { multiple: true, .. } => { + "COINCIDENT Select points to coincide with the first object (Enter = done):" + .to_string() + } + Step::AutoConstrain { handles } if handles.is_empty() => { + "COINCIDENT Select objects:".to_string() + } + Step::AutoConstrain { handles } => format!( + "COINCIDENT Select objects ({} selected, Enter = apply):", + handles.len() + ), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match self.first { - None => { - self.first = Some(pt); + match &self.step { + Step::First => { + self.step = Step::Second(Self::point_pick(None, pt)); CmdResult::NeedPoint } - Some(first) => CmdResult::AddCoincidentConstraint { - point_a: first, - point_b: pt, + Step::FirstObject => CmdResult::NeedPoint, + Step::Second(first) => CmdResult::AddCoincidentConstraint { + first: *first, + second: Self::point_pick(None, pt), + multiple: false, label: "Coincident constraint", }, + Step::PointsOnCurve { curve, multiple } => CmdResult::AddCoincidentConstraint { + first: *curve, + second: Self::point_pick(None, pt), + multiple: *multiple, + label: "Coincident constraint", + }, + Step::AutoConstrain { .. } => CmdResult::NeedPoint, } } + fn options(&self) -> Vec { + match self.step.clone() { + Step::First => vec![ + CmdOption::new("Object", "O"), + CmdOption::new("Autoconstrain", "A"), + ], + Step::PointsOnCurve { multiple: false, .. } => vec![ + CmdOption::new("Point", "P"), + CmdOption::new("Multiple", "M"), + ], + Step::PointsOnCurve { multiple: true, .. } => vec![CmdOption::enter("Done")], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + match (&mut self.step, keyword.as_str()) { + (Step::First, "O" | "OBJECT") => { + self.step = Step::FirstObject; + Some(CmdResult::NeedPoint) + } + (Step::First, "A" | "AUTOCONSTRAIN") => { + self.step = Step::AutoConstrain { handles: Vec::new() }; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { multiple, .. }, "M" | "MULTIPLE") => { + *multiple = true; + Some(CmdResult::NeedPoint) + } + (Step::PointsOnCurve { .. }, "P" | "POINT") => Some(CmdResult::NeedPoint), + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + !matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let is_point = self + .picked_entity + .as_ref() + .is_some_and(|entity| Self::picked_constraint_point(entity, point)); + let is_curve = self + .picked_entity + .as_ref() + .is_some_and(Self::valid_curve); + self.picked_entity = None; + + match self.step.clone() { + Step::FirstObject if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::First if is_point => { + self.step = Step::Second(Self::point_pick(Some(handle), point)); + CmdResult::NeedPoint + } + Step::First if is_curve => { + self.step = Step::PointsOnCurve { + curve: Self::curve_pick(handle, point), + multiple: false, + }; + CmdResult::NeedPoint + } + Step::Second(first) if is_point => CmdResult::AddCoincidentConstraint { + first, + second: Self::point_pick(Some(handle), point), + multiple: false, + label: "Coincident constraint", + }, + Step::Second(first) if is_curve => CmdResult::AddCoincidentConstraint { + first, + second: Self::curve_pick(handle, point), + multiple: false, + label: "Coincident constraint", + }, + Step::PointsOnCurve { curve, multiple } if is_point => { + CmdResult::AddCoincidentConstraint { + first: curve, + second: Self::point_pick(Some(handle), point), + multiple, + label: "Coincident constraint", + } + } + _ => CmdResult::NeedPoint, + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(&self.step, Step::AutoConstrain { .. }) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if let Step::AutoConstrain { handles: selected } = &mut self.step { + *selected = handles; + } + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if matches!(&self.step, Step::First) { + self.step = Step::FirstObject; + return CmdResult::NeedPoint; + } + if let Step::AutoConstrain { handles } = &self.step { + if !handles.is_empty() { + return CmdResult::AddAutoCoincidentConstraints { + handles: handles.clone(), + }; + } + } CmdResult::Cancel } @@ -80,5 +297,63 @@ impl CadCommand for CoincidentConstraintCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["CCONSTRAINT"] + names: &["CCONSTRAINT", "GCCOINCIDENT"] }); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn multiple_keeps_the_first_curve_for_repeated_points() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Object"), + Some(CmdResult::NeedPoint) + )); + command.inject_picked_entity(EntityType::Line( + acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X), + )); + let handle = Handle::new(7); + assert!(matches!( + command.on_entity_pick(handle, DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_text_input("Multiple"), + Some(CmdResult::NeedPoint) + )); + + assert!(matches!( + command.on_point(DVec3::X), + CmdResult::AddCoincidentConstraint { + first: CoincidentPick { + handle: Some(found), + whole_curve: true, + .. + }, + multiple: true, + .. + } if found == handle + )); + } + + #[test] + fn automatic_mode_returns_the_completed_selection() { + let mut command = CoincidentConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Autoconstrain"), + Some(CmdResult::NeedPoint) + )); + let handles = vec![Handle::new(3), Handle::new(5)]; + assert!(matches!( + command.on_selection_complete(handles.clone()), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_enter(), + CmdResult::AddAutoCoincidentConstraints { handles: found } if found == handles + )); + } +} diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs new file mode 100644 index 000000000..9f3189f8c --- /dev/null +++ b/src/modules/parametric/geom_constraint.rs @@ -0,0 +1,105 @@ +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +/// Command-line front end for the standard geometric-constraint family. +/// Each choice is handed to the existing focused command so ribbon and typed +/// entry use one implementation. +pub struct GeomConstraintCommand; + +impl GeomConstraintCommand { + pub fn new() -> Self { + Self + } + + fn dispatch(keyword: &str) -> Option<&'static str> { + Some(match keyword { + "H" | "HORIZONTAL" => "HCONSTRAINT", + "V" | "VERTICAL" => "VCONSTRAINT", + "P" | "PERPENDICULAR" => "QCONSTRAINT", + "PA" | "PARALLEL" => "PCONSTRAINT", + "T" | "TANGENT" => "TCONSTRAINT", + "SM" | "SMOOTH" => "SMOOTHCONSTRAINT", + "C" | "COINCIDENT" => "GCCOINCIDENT", + "CON" | "CONCENTRIC" => "NCONSTRAINT", + "COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT", + "SY" | "SYMMETRIC" => "SYCONSTRAINT", + "E" | "EQUAL" => "ECONSTRAINT", + "F" | "FIX" => "FXCONSTRAINT", + _ => return None, + }) + } +} + +impl CadCommand for GeomConstraintCommand { + fn name(&self) -> &'static str { + "GEOMCONSTRAINT" + } + + fn prompt(&self) -> String { + "GEOMCONSTRAINT Enter constraint type [Horizontal/Vertical/Perpendicular/Parallel/Tangent/Smooth/Coincident/Concentric/Collinear/Symmetric/Equal/Fix] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Horizontal", "H"), + CmdOption::new("Vertical", "V"), + CmdOption::new("Perpendicular", "P"), + CmdOption::new("Parallel", "PA"), + CmdOption::new("Tangent", "T"), + CmdOption::new("Smooth", "SM"), + CmdOption::new("Coincident", "C"), + CmdOption::new("Concentric", "CON"), + CmdOption::new("Collinear", "COL"), + CmdOption::new("Symmetric", "SY"), + CmdOption::new("Equal", "E"), + CmdOption::new("Fix", "F"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase(); + Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string())) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("GCCOINCIDENT".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["GEOMCONSTRAINT"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooser_dispatches_an_existing_constraint_command() { + let mut command = GeomConstraintCommand::new(); + assert!(matches!( + command.on_text_input("Parallel"), + Some(CmdResult::Dispatch(found)) if found == "PCONSTRAINT" + )); + assert!(matches!( + command.on_enter(), + CmdResult::Dispatch(found) if found == "GCCOINCIDENT" + )); + } +} diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..fd82ee55b 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -4,11 +4,13 @@ mod coincident; mod equal_distance; +mod geom_constraint; mod point_on_entity; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use geom_constraint::GeomConstraintCommand; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; @@ -128,7 +130,7 @@ impl CadModule for ParametricModule { inventory::submit!(crate::command::CommandRegistration { names: &[ - "AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", + "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "DCCONVERT", "DELCONSTRAINT", ] 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..94e304f12 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,16 +319,24 @@ 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) } fn on_text_input(&mut self, text: &str) -> Option { + // An existing parameter name wins over a mode keyword with the same spelling. + if let Some(value @ DrivingValue::Named(_)) = + parse_driving_value(text, &self.known_param_names) + { + return self.build(value); + } // Mode-switch keywords re-prompt instead of building — consumed // inputs return `Some(NeedPoint)` so the driver doesn't re-offer // the same token a second time (matches e.g. `trim.rs`'s @@ -314,10 +349,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 +421,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 +468,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 +547,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 +564,7 @@ mod tests { ); match cmd.on_text_input("10") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -545,7 +572,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 +585,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 +603,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)), .. @@ -584,6 +611,24 @@ mod tests { )); } + #[test] + fn a_parameter_named_like_a_mode_keyword_remains_usable() { + let mut scene = Scene::new(); + let circle = add_circle(&mut scene); + scene.named_parameters_mut().set("r", "5").unwrap(); + let mut cmd = + DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT"); + + assert!(matches!( + cmd.on_text_input("R"), + Some(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Radius, + driving_param: Some(DrivingValue::Named(name)), + .. + }) if name == "r" + )); + } + #[test] fn ordinary_distances_reject_nonpositive_and_nonfinite_values() { let mut scene = Scene::new(); @@ -609,7 +654,7 @@ mod tests { ); match cmd.on_text_input("16") { - Some(CmdResult::AddSketchConstraint { + Some(CmdResult::AddParametricConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. @@ -617,7 +662,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 +674,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/cache/properties.rs b/src/scene/cache/properties.rs index 4a08c1571..4490e24ea 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -32,7 +32,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); - let mut section = PropSection { + let section = PropSection { title: t!("General").into_owned(), props: vec![ Property { @@ -88,15 +88,6 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - // Thickness (DXF 39) is a General-group property, but only the entity - // types that carry an extrusion thickness expose it (line, circle, arc, - // polyline, text, 2D solid, …). Show it right after Hyperlink for those. - if let Some(t) = crate::scene::view::dispatch::entity_thickness(entity) { - section - .props - .push(crate::entities::common::edit_prop(t!("Thickness").as_ref(), "thickness", t)); - } - section } 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 0f7d76cec..592f709f1 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,7 +11,10 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::FxHashMap; use super::named_parameters::{DrivingValue, ParameterTable}; -use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef}; +use super::parametric_constraints::{ + distance_direction_type, ConstraintKind, NativeConstraintOrigin, ParametricConstraint, + ParametricConstraintSet, ParametricRef, ParametricScope, +}; use super::Scene; /// Default sub-dictionary key used for an associative network. @@ -26,19 +28,21 @@ 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 { geometry_node_id: i32, points: FxHashMap, + segments: FxHashMap, } /// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, @@ -51,6 +55,8 @@ struct GroupBuilder<'a> { /// Real entity handles that ended up with a geometry node — what /// `AssocGeomDependency` objects get created for, in first-touch order. referenced_entities: Vec, + horizontal_datum: Option, + vertical_datum: Option, } impl<'a> GroupBuilder<'a> { @@ -61,6 +67,8 @@ impl<'a> GroupBuilder<'a> { next_node_id: FIRST_NODE_ID, entities: FxHashMap::default(), referenced_entities: Vec::new(), + horizontal_datum: None, + vertical_datum: None, } } @@ -81,6 +89,7 @@ impl<'a> GroupBuilder<'a> { | AssocConstraintNodeData::Arc { .. } | AssocConstraintNodeData::Ellipse { .. } | AssocConstraintNodeData::BoundedEllipse { .. } + | AssocConstraintNodeData::Spline { .. } )); self.nodes.push(AssocConstraintNode { node_id, @@ -92,24 +101,7 @@ impl<'a> GroupBuilder<'a> { }); } - /// The geometry node id for `entity`'s whole shape, creating a `Line` - /// (well: `BoundedLine`, §4 — nothing we draw is an infinite - /// construction line), `Circle`, or `Arc` node the first time this - /// entity is referenced. Returns `None` for an entity kind this pass - /// doesn't support as constraint geometry — currently including - /// `Ellipse` (constraint-parity Phase 5): the solver - /// (`sketch_solve.rs`) and this app's own XRecord format both support - /// ellipse-referencing constraints already, but `acadrust`'s - /// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a - /// different field shape from `Circle`/`Arc`'s here (`owner_id`/ - /// `is_implied`/`is_active` instead of `geometry_dependency`/ - /// `geometry_node_id`, i.e. they don't obviously look like the same - /// "geometry dependency for an entity reference" role) — without - /// confirming that against a real externally written file, guessing would - /// risk writing a wrong-but-plausible-looking object graph, which is - /// worse than the honest "not persisted to DWG yet" gap this already - /// degrades to (same contract as `ArcLength`, which has no native - /// representation at all). + /// Returns the whole-geometry node for an entity, creating it once. fn geometry_node(&mut self, handle: Handle) -> Option { if let Some(existing) = self.entities.get(&handle) { if existing.geometry_node_id != 0 { @@ -119,6 +111,71 @@ impl<'a> GroupBuilder<'a> { let entity = self.document.get_entity(handle)?; let node_id = self.alloc_node_id(); match entity { + EntityType::Point(point) => { + self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(point.location), + }, + ); + } + EntityType::Insert(insert) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(insert.insert_point), + }, + ), + EntityType::Text(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::MText(text) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(text.insertion_point), + }, + ), + EntityType::AttributeDefinition(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::AttributeEntity(attribute) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(attribute.insertion_point), + }, + ), + EntityType::Table(table) => self.push_node( + node_id, + "AcConstrainedPoint", + AssocConstraintNodeData::Point { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Some(table.insertion_point), + }, + ), EntityType::Line(l) => { let point = l.start; let direction = (l.end - l.start).normalize(); @@ -140,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)); @@ -187,6 +271,122 @@ impl<'a> GroupBuilder<'a> { }, ); } + EntityType::Ellipse(e) => { + let major_length = e.major_axis.length(); + let major = if major_length > f64::EPSILON { + e.major_axis / major_length + } else { + Vector3::UNIT_X + }; + let normal = e.normal.normalize(); + let minor = Vector3::new( + normal.y * major.z - normal.z * major.y, + normal.z * major.x - normal.x * major.z, + normal.x * major.y - normal.y * major.x, + ); + let point_at = |parameter: f64| { + e.center + + e.major_axis * parameter.cos() + + minor * (major_length * e.minor_axis_ratio * parameter.sin()) + }; + let data = if e.is_full() { + AssocConstraintNodeData::Ellipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + } + } else { + AssocConstraintNodeData::BoundedEllipse { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: e.center, + major_axis: e.major_axis, + axis_ratio: e.minor_axis_ratio, + start_point: point_at(e.start_parameter), + end_point: point_at(e.end_parameter), + } + }; + let class_name = if e.is_full() { + "AcConstrainedEllipse" + } else { + "AcConstrainedBoundedEllipse" + }; + 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; @@ -206,12 +406,51 @@ impl<'a> GroupBuilder<'a> { { return Some(*existing); } - let curve_id = self.geometry_node(handle)?; - let point_type = match marker { - 0 => implicit_point_type::START, - 1 => implicit_point_type::END, - -3 => implicit_point_type::CENTER, - _ => return None, + 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 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 { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }; + let vertex = marker as usize; + if vertex >= points.len() { + return None; + } + let (segment, point_type) = if vertex + 1 < points.len() || closed { + (vertex, implicit_point_type::START) + } else { + (vertex.checked_sub(1)?, implicit_point_type::END) + }; + (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, + }; + (curve_id, point_type) }; let node_id = self.alloc_node_id(); self.push_node( @@ -226,6 +465,28 @@ impl<'a> GroupBuilder<'a> { 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() @@ -234,15 +495,146 @@ 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); + } + if r.marker == Some(0) + && matches!( + self.document.get_entity(r.entity), + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_) + ) + ) + { + return self.geometry_node(r.entity); + } match r.marker { Some(marker) => self.point_node(r.entity, marker), None => self.geometry_node(r.entity), } } + fn segment_node(&mut self, handle: Handle, index: usize) -> Option { + if let Some(node_id) = self + .entities + .get(&handle) + .and_then(|entity| entity.segments.get(&index)) + { + return Some(*node_id); + } + let entity = self.document.get_entity(handle)?; + let points = super::dimension_assoc::source_points(entity); + let (closed, bulge) = match entity { + EntityType::LwPolyline(polyline) => { + (polyline.is_closed, polyline.vertices.get(index)?.bulge) + } + EntityType::Polyline2D(polyline) => { + (polyline.is_closed(), polyline.vertices.get(index)?.bulge) + } + _ => return None, + }; + let start = *points.get(index)?; + let end = if index + 1 < points.len() { + points[index + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + let node_id = self.alloc_node_id(); + if bulge.abs() <= 1e-9 { + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: (end - start).normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + } else { + let arc = cadkernel::geom2d::BulgeArc::from_bulge( + [start.x, start.y], + [end.x, end.y], + bulge, + )?; + self.push_node( + node_id, + "AcConstrainedArc", + AssocConstraintNodeData::Arc { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + center: Vector3::new(arc.center[0], arc.center[1], start.z), + normal: Vector3::UNIT_Z, + direction: Vector3::UNIT_X, + radius: arc.radius, + start_parameter: arc.start_angle, + end_parameter: arc.start_angle + arc.sweep, + reserved: 0.0, + start_point: start, + end_point: end, + }, + ); + } + self.entities + .entry(handle) + .or_default() + .segments + .insert(index, node_id); + if !self.referenced_entities.contains(&handle) { + self.referenced_entities.push(handle); + } + Some(node_id) + } + + fn datum_line(&mut self, horizontal: bool) -> i32 { + let cached = if horizontal { + self.horizontal_datum + } else { + self.vertical_datum + }; + if let Some(node_id) = cached { + return node_id; + } + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedDatumLine", + AssocConstraintNodeData::Line { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: Vector3::ZERO, + direction: if horizontal { + Vector3::UNIT_X + } else { + Vector3::UNIT_Y + }, + }, + ); + if horizontal { + self.horizontal_datum = Some(node_id); + } else { + self.vertical_datum = Some(node_id); + } + node_id + } + /// Records an undirected edge on both nodes. fn connect(&mut self, a: i32, b: i32) { if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) { @@ -258,11 +650,8 @@ impl<'a> GroupBuilder<'a> { fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { match kind { ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"), - ConstraintKind::Horizontal => Some("AcHorizontalConstraint"), - ConstraintKind::Vertical => Some("AcVerticalConstraint"), 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. @@ -281,21 +670,19 @@ 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 | ConstraintKind::Distance | ConstraintKind::Angle | 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, } } @@ -306,10 +693,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", _ => "", } @@ -322,29 +711,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 { @@ -353,6 +858,36 @@ fn constraint_node( return Some(node_id); } match constraint.kind { + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + if !matches!(refs.len(), 1 | 2) { + return None; + } + let targets: Vec<_> = refs + .iter() + .map(|reference| builder.ref_node(*reference)) + .collect::>()?; + let horizontal = constraint.kind == ConstraintKind::Horizontal; + let datum = builder.datum_line(horizontal); + let node_id = builder.alloc_node_id(); + builder.push_node( + node_id, + if horizontal { + "AcHorizontalConstraint" + } else { + "AcVerticalConstraint" + }, + AssocConstraintNodeData::Parallel { + owner_id: 0, + is_implied: false, + is_active: constraint.enabled, + datum_line_index: Some(datum), + }, + ); + for target in targets { + builder.connect(node_id, target); + } + Some(node_id) + } ConstraintKind::Parallel => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); @@ -361,9 +896,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, }, ); @@ -374,8 +909,17 @@ fn constraint_node( ConstraintKind::Equal => { let [a, b] = refs else { return None }; let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?); - let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) - && matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); + let is_straight = |reference: &ParametricRef| { + matches!( + document.get_entity(reference.entity), + Some(EntityType::Line(_)) + ) || (reference.segment_index().is_some() + && matches!( + document.get_entity(reference.entity), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) + )) + }; + let both_lines = is_straight(a) && is_straight(b); let class_name = if both_lines { "AcEqualLengthConstraint" } else { @@ -386,9 +930,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); @@ -398,29 +942,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 @@ -430,33 +992,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, @@ -466,7 +1045,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, @@ -586,6 +1165,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( @@ -657,7 +1244,15 @@ impl Allocator<'_> { /// null dependency and refer to their owning geometry node by `curve_id`. fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { match data { - AssocConstraintNodeData::BoundedLine { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { geometry_dependency, .. } @@ -668,6 +1263,18 @@ fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { | AssocConstraintNodeData::Arc { geometry_dependency, .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. } => { *geometry_dependency = handle; } @@ -736,18 +1343,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. @@ -837,27 +1434,823 @@ 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() + 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 { + continue; + }; + let AssociativeData::ConstraintGroup(group) = &object.data else { + continue; + }; + if (object.owner == network_handle || group.action.owning_network == network_handle) + && !handles.contains(handle) + { + handles.push(*handle); + } + } + handles } -/// 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 native_scope_network_handle(document: &CadDocument, owner: Handle) -> Option { + let dictionary = document.extension_dictionary_handle(owner)?; + let ObjectType::Dictionary(dictionary) = document.objects.get(&dictionary)? else { + return None; + }; + let mut network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + while let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&network_handle) { + network_handle = dictionary.get(NETWORK_DICTIONARY_KEY)?; + } + matches!( + document.objects.get(&network_handle), + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Network(_), + .. + })) + ) + .then_some(network_handle) +} + +fn geometry_dependency(data: &AssocConstraintNodeData) -> Option { + match data { + AssocConstraintNodeData::Point { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Line { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedLine { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Circle { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Arc { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Ellipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::BoundedEllipse { + geometry_dependency, + .. + } + | AssocConstraintNodeData::Spline { + geometry_dependency, + .. + } if !geometry_dependency.is_null() => Some(*geometry_dependency), + _ => None, + } +} + +fn dependency_entity( + document: &CadDocument, + dependency: Handle, + geometry: &AssocConstraintNodeData, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::GeomDependency(dependency) = &object.data else { + return None; + }; + let entity = dependency.dependency.dependent_on; + let supported = match (document.get_entity(entity), geometry) { + ( + Some( + EntityType::Point(_) + | EntityType::Insert(_) + | EntityType::Text(_) + | EntityType::MText(_) + | EntityType::AttributeDefinition(_) + | EntityType::AttributeEntity(_) + | EntityType::Table(_), + ), + AssocConstraintNodeData::Point { .. }, + ) => true, + (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, + ( + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), + AssocConstraintNodeData::BoundedLine { .. } | AssocConstraintNodeData::Arc { .. }, + ) => 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, + ( + Some(EntityType::Ellipse(_)), + AssocConstraintNodeData::Ellipse { .. } + | AssocConstraintNodeData::BoundedEllipse { .. }, + ) => true, + (Some(EntityType::Spline(_)), AssocConstraintNodeData::Spline { .. }) => true, + _ => false, + }; + supported.then_some(entity) +} + +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; + } + } + 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" + ) + }) +} + +fn polyline_segment_reference( + document: &CadDocument, + entity: Handle, + data: &AssocConstraintNodeData, + work_plane: &[Vector3; 3], +) -> Option { + let (start_point, end_point) = match data { + AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } + | AssocConstraintNodeData::Arc { + start_point, + end_point, + .. + } => (*start_point, *end_point), + _ => 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, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => return None, + }; + let points = super::dimension_assoc::source_points(entity_value); + let segment_count = points.len().saturating_sub(usize::from(!closed)); + (0..segment_count) + .min_by(|first, second| { + 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(); + forward.min(reverse) + }; + score(*first).total_cmp(&score(*second)) + }) + .map(|index| ParametricRef::segment(entity, index)) +} + +fn numeric_value(value: &AssocEvalVariant) -> Option { + match value.value { + AssocEvalValue::Real(value) => Some(value), + AssocEvalValue::Long(value) => Some(value as f64), + AssocEvalValue::Short(value) => Some(value as f64), + AssocEvalValue::Byte(value) => Some(value as f64), + _ => None, + } +} + +fn driving_value( + document: &CadDocument, + dependency: Handle, + parameters: &mut ParameterTable, +) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::ValueDependency(value_dependency) = &object.data else { + return None; + }; + let literal = numeric_value(&value_dependency.value); + let ObjectType::Associative(variable) = document + .objects + .get(&value_dependency.dependency.dependent_on)? + else { + return literal.map(DrivingValue::Literal); + }; + let AssociativeData::Variable(variable) = &variable.data else { + return literal.map(DrivingValue::Literal); + }; + let source = if variable.expression.trim().is_empty() { + numeric_value(&variable.value)?.to_string() + } else { + variable.expression.clone() + }; + if parameters.set(&variable.name, &source).is_ok() { + Some(DrivingValue::Named(variable.name.clone())) + } else { + literal + .or_else(|| numeric_value(&variable.value)) + .map(DrivingValue::Literal) + } +} + +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, .. } + | AssocConstraintNodeData::RadiusDiameter { owner_id, .. } => Some(*owner_id), + _ => None, + } +} + +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, .. } + | AssocConstraintNodeData::RadiusDiameter { is_active, .. } => *is_active, + _ => true, + } +} + +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" => ConstraintKind::Angle, + "AC3POINTANGLECONSTRAINT" => ConstraintKind::Angle3Point, + "ACTANGENTCONSTRAINT" => ConstraintKind::Tangent, + "ACG2SMOOTHCONSTRAINT" => ConstraintKind::Smooth, + "ACCONCENTRICCONSTRAINT" => ConstraintKind::Concentric, + "ACCENTERPOINTCONSTRAINT" => ConstraintKind::CenterPoint, + "ACCOLINEARCONSTRAINT" => ConstraintKind::Colinear, + "ACMIDPOINTCONSTRAINT" => ConstraintKind::Midpoint, + "ACFIXEDCONSTRAINT" => ConstraintKind::Fixed, + "ACPOINTCURVECONSTRAINT" => ConstraintKind::PointOnCurve, + "ACEQUALDISTANCECONSTRAINT" => ConstraintKind::EqualDistance, + "ACSYMMETRICCONSTRAINT" => ConstraintKind::Symmetric, + "ACNORMALCONSTRAINT" => ConstraintKind::Normal, + "ACRADIUSDIAMETERCONSTRAINT" => { + let AssocConstraintNodeData::RadiusDiameter { mode, .. } = node.data else { + return None; + }; + if mode == 1 { + ConstraintKind::Diameter + } else { + ConstraintKind::Radius + } + } + "ACDISTANCECONSTRAINT" => { + let AssocConstraintNodeData::Distance { + direction_type, + distance, + .. + } = &node.data + else { + return None; + }; + if *direction_type == distance_direction_type::UNDIRECTED { + ConstraintKind::Distance + } 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 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 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, + }); + } + } + } + } + (!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), + .. + })) = document.objects.get_mut(&group_handle) + else { + continue; + }; + let mut removed = Vec::new(); + for node in &mut group.nodes { + if constraint_kind(node).is_none() || constraint_is_implied(&node.data) { + continue; + } + 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); + } + } +} + +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); @@ -867,9 +2260,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, @@ -885,33 +2275,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() { @@ -919,15 +2337,19 @@ fn materialize_scope( continue; }; if entity_nodes.geometry_node_id == 0 { - continue; + if entity_nodes.segments.is_empty() { + 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); - if let Some(node) = nodes - .iter_mut() - .find(|n| n.node_id == entity_nodes.geometry_node_id) + for node_id in std::iter::once(entity_nodes.geometry_node_id) + .filter(|node_id| *node_id != 0) + .chain(entity_nodes.segments.values().copied()) { - set_geometry_dependency(&mut node.data, dep_handle); + if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) { + set_geometry_dependency(&mut node.data, dep_handle); + } } } @@ -950,36 +2372,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 { @@ -989,12 +2425,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, @@ -1025,30 +2464,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(); - 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() { + if !owner.is_null() && !owners.contains(&owner) { + owners.push(owner); + } + } + 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; } - if !enabled && !has_native_network(&self.document, owner) { - continue; + if let Some(set) = &set { + sync_retained_group_constraints(&mut self.document, set); } - scopes.push((owner, 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; @@ -1063,14 +2590,22 @@ impl Scene { ); let root_network_handle = self.document.allocate_handle(); let mut child_networks = Vec::new(); - for (index, (owner, set)) in scopes.into_iter().enumerate() { + 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); } @@ -1117,9 +2652,14 @@ 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, Line}; + use crate::scene::ChangeKind; + use acadrust::entities::{Arc, Circle, Insert, Line, LwPolyline, Ray, Spline, XLine}; + use acadrust::tables::BlockRecord; + use acadrust::types::Vector2; fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle { scene.add_entity(EntityType::Line(Line::from_points( @@ -1199,21 +2739,28 @@ 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; - // Sanity before the round trip even happens: root + 2 geometry - // nodes + 2 constraint nodes. + // Root + two geometry nodes + two datums + two constraints. let before = native_group(&scene.document, owner); assert_eq!( before.nodes.len(), - 5, - "expected root + 2 geometry + 2 constraint nodes, got {:#?}", + 7, + "expected root + 2 geometry + 2 datums + 2 constraints, got {:#?}", before.nodes ); @@ -1241,6 +2788,585 @@ mod tests { 2, "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" ); + let datums: Vec = group + .nodes + .iter() + .filter_map(|node| match node.data { + AssocConstraintNodeData::Parallel { + datum_line_index: Some(datum), + .. + } if matches!( + node.class_name.as_str(), + "AcHorizontalConstraint" | "AcVerticalConstraint" + ) => + { + Some(datum) + } + _ => None, + }) + .collect(); + assert_eq!(datums.len(), 2, "{ext}: both constraints need a datum"); + assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| { + node.node_id == *datum && node.class_name == "AcConstrainedDatumLine" + }))); + } + } + + #[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.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(a)], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + set.add( + ConstraintKind::DistanceY, + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], + Some(DrivingValue::Named("gap".to_string())), + ); + + 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_parametric_constraints_from_document(); + + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("native group should become an editable constraint set"); + let kinds: Vec<_> = set + .constraints + .iter() + .map(|constraint| constraint.kind) + .collect(); + assert_eq!( + kinds, + vec![ + ConstraintKind::Horizontal, + ConstraintKind::Parallel, + ConstraintKind::DistanceY, + ] + ); + assert_eq!(restored.named_parameters.resolve("gap"), Ok(5.0)); + assert_eq!( + set.constraints[2].driving_param, + Some(DrivingValue::Named("gap".to_string())) + ); + } + + #[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(); + let mut block = BlockRecord::new("fixture"); + block.handle = scene.document.allocate_handle(); + scene.document.block_records.add(block).unwrap(); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(1.0, 2.0, 0.0), + ))); + let insert = scene.add_entity(EntityType::Insert(Insert::new( + "fixture", + 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.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Horizontal, + vec![ + ParametricRef::point(point, 0), + ParametricRef::point(insert, 0), + ], + None, + ); + set.add( + ConstraintKind::Midpoint, + vec![ParametricRef::point(point, 0), ParametricRef::whole(line)], + None, + ); + + 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_parametric_constraints_from_document(); + + let constraints = &restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("native point constraints should be imported") + .constraints; + assert_eq!(constraints.len(), 2); + assert_eq!(constraints[0].kind, ConstraintKind::Horizontal); + assert_eq!(constraints[0].refs.len(), 2); + assert_eq!(constraints[1].kind, ConstraintKind::Midpoint); + } + + #[test] + fn polyline_segment_constraints_round_trip_through_the_native_graph() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(5.0, 2.0), + Vector2::new(10.0, 7.0), + ]))); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::segment(polyline, 1)], + None, + ); + + 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_parametric_constraints_from_document(); + + let set = restored + .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![ParametricRef::segment(polyline, 1)] + ); + } + + #[test] + fn bulged_polyline_point_on_curve_round_trips_with_its_segment() { + for ext in ["dwg", "dxf"] { + let mut source = LwPolyline::new(); + source.add_point(Vector2::new(0.0, 0.0)); + source.add_point_with_bulge(Vector2::new(5.0, 0.0), 0.5); + source.add_point(Vector2::new(10.0, 5.0)); + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(source)); + let point = line_entity(&mut scene, (8.0, 2.0), (9.0, 2.0)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![ + ParametricRef::point(point, 0), + ParametricRef::segment(polyline, 1), + ], + None, + ); + + scene.sync_native_parametric_graph(); + let group = native_group( + &scene.document, + scene.document.header.model_space_block_handle, + ); + assert!(group + .nodes + .iter() + .any(|node| node.class_name == "AcConstrainedArc")); + + 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!("bulged_segment.{ext}"), bytes) + .unwrap_or_else(|error| panic!("reload {ext}: {error}")); + restored.load_parametric_constraints_from_document(); + + let set = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .expect("native point-on-curve constraint should be imported"); + assert_eq!(set.constraints.len(), 1, "{ext}"); + assert_eq!(set.constraints[0].kind, ConstraintKind::PointOnCurve); + assert_eq!( + set.constraints[0].refs, + vec![ + ParametricRef::point(point, 0), + ParametricRef::segment(polyline, 1), + ], + "{ext}" + ); } } @@ -1253,10 +3379,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; @@ -1273,14 +3399,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" @@ -1367,11 +3489,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}")); @@ -1384,7 +3505,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() @@ -1392,8 +3513,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" ); } @@ -1402,12 +3523,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, @@ -1415,11 +3540,125 @@ mod tests { ); } + #[test] + 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 + .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); + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ConstraintGroup(group), + .. + })) = scene.document.objects.get_mut(&group_handle) + 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: 0, + reserved: false, + 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.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)); + + 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!(groups.iter().any(|group| group + .nodes + .iter() + .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); @@ -1434,77 +3673,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" ); } @@ -1525,59 +3716,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) @@ -1609,7 +3816,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 @@ -1623,19 +3830,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) @@ -1666,7 +3873,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 @@ -1679,14 +3886,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) @@ -1711,16 +3918,8 @@ mod tests { } } - /// Constraint-parity Phase 5: an ellipse isn't a supported geometry - /// node type in `geometry_node` yet (see its own doc comment for why), - /// so a Concentric constraint referencing one degrades to "not in the - /// native graph" — the same "skip, don't error" contract every other - /// unbuildable native-constraint case gets — while the other - /// constraint in the same scope (Fixed, on a plain circle) still - /// persists normally, and the ellipse constraint itself still survives - /// in this app's own XRecord format. #[test] - fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() { + fn an_ellipse_constraint_round_trips_in_the_native_graph() { for ext in ["dxf", "dwg"] { let mut scene = Scene::new(); let ellipse = scene.add_entity(EntityType::Ellipse( @@ -1732,16 +3931,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) @@ -1757,91 +3962,26 @@ mod tests { "{ext}: the other constraint should still persist, got {class_names:?}" ); assert!( - !class_names.contains(&"AcConcentricConstraint"), - "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" + class_names.contains(&"AcConcentricConstraint"), + "{ext}: missing ellipse-referencing constraint, got {class_names:?}" + ); + assert!( + class_names.contains(&"AcConstrainedEllipse"), + "{ext}: missing ellipse geometry node, got {class_names:?}" ); 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:?}" ); } } @@ -1860,24 +4000,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) @@ -1923,4 +4063,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..8a5ee23f4 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); @@ -1870,7 +1834,7 @@ impl Scene { .map(|point| to_xy(point[0], point[1])) .collect(); rings.push(ring); - local_rings.push(local_ring); + local_rings.push((local_ring, path)); ring_sources.push(path.boundary_handles.clone()); } @@ -1883,7 +1847,8 @@ impl Scene { let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); - for (((ring, local_ring), sources), depth) in rings + let mut spline_paths = Vec::new(); + for (((ring, (local_ring, path)), sources), depth) in rings .into_iter() .zip(local_rings) .zip(ring_sources) @@ -1901,12 +1866,14 @@ impl Scene { boundary.push([f64::NAN, f64::NAN]); local_boundary.push([f32::NAN, f32::NAN]); } + let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { + spline_paths.push((path, local_ring, range)); + } boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -1941,18 +1908,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 +1943,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 +2010,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() { @@ -2095,6 +2075,26 @@ impl Scene { } } } + let project = |point: [f64; 2]| { + let [x, y] = to_xy(point[0], point[1]); + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + }; + for (path, ring, range) in spline_paths.into_iter().rev() { + if range.end > boundary.len() { + continue; + } + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + if let Some(refined) = + cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project) + { + if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS { + continue; + } + boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + } + } + let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { @@ -2129,8 +2129,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 +2176,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 +2218,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 +2320,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 +2369,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 +2488,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 +2512,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 +2590,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 +2644,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 +2705,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/group_layer.rs b/src/scene/group_layer.rs index e636bde8f..7c88d7f65 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,6 +203,42 @@ impl Scene { self.bump_selection_set(); } } + + /// Partition selected entity handles into visual objects for operations + /// such as edge alignment. A selectable CAD group is one object and must + /// receive one shared transform; treating its member lines independently + /// can collapse a rectangle or triangle onto its own edge. + pub fn selected_object_units(&self, handles: &[Handle]) -> Vec> { + let selected: HashSet = handles.iter().copied().collect(); + let mut claimed = HashSet::default(); + let mut units = Vec::new(); + for group in self.groups().filter(|group| group.selectable) { + let members: Vec<_> = group + .entities + .iter() + .copied() + .filter(|handle| selected.contains(handle)) + .collect(); + if !members.is_empty() + && members.iter().all(|handle| !claimed.contains(handle)) + && group + .entities + .iter() + .all(|handle| selected.contains(handle)) + { + claimed.extend(members.iter().copied()); + units.push(members); + } + } + units.extend( + handles + .iter() + .copied() + .filter(|handle| !claimed.contains(handle)) + .map(|handle| vec![handle]), + ); + units + } } #[cfg(test)] @@ -247,4 +283,31 @@ mod group_expansion_tests { assert_eq!(both, [a, b, c, d, e].into_iter().collect::>()); assert!(!both.contains(&lonely)); } + + /// `selected_object_units` is `Scene::selected_object_units`'s + /// counterpart for operations like edge alignment (`ALIGNTOP`, etc.): + /// a complete, fully-selected group collapses to one unit sharing a + /// single transform, so aligning a triangle's edge to another object + /// moves the whole triangle rather than collapsing its own lines onto + /// each other. + #[test] + fn selected_object_units_keep_a_complete_group_together() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut line = |x: f64| { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + }; + let (a, b, c, standalone) = (line(0.0), line(1.0), line(2.0), line(10.0)); + scene.create_group("triangle".into(), vec![a, b, c]); + + let units = scene.selected_object_units(&[a, b, c, standalone]); + assert_eq!(units.len(), 2); + assert!(units.iter().any(|unit| unit == &vec![a, b, c])); + assert!(units.iter().any(|unit| unit == &vec![standalone])); + } } diff --git a/src/scene/hatch_boundary.rs b/src/scene/hatch_boundary.rs new file mode 100644 index 000000000..87c277195 --- /dev/null +++ b/src/scene/hatch_boundary.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod tests; diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs new file mode 100644 index 000000000..e6362d232 --- /dev/null +++ b/src/scene/hatch_boundary/tests.rs @@ -0,0 +1,236 @@ +use crate::scene::Scene; +use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge}; +use acadrust::types::{Vector2, Vector3}; +use cadkernel::geom2d::{refine_spline_boundary, triangulate_rings, Curve, NurbsCurve}; +use cadkernel::tessellation::DEFAULT_ANGLE; + +fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { + let line = |a: [f64; 2], b: [f64; 2]| { + [ + a, + std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0), + std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0), + b, + ] + }; + let [a, b, c, d, e, f] = [ + [-1.0, -1.0], + [0.0, -1.0], + [0.0, 1.0], + [clearance, 0.999], + [-0.5, 1.5], + [-1.0, 1.5], + ]; + // The returning cubic passes close to the vertical side b--c. + let spans = [ + line(a, b), + line(b, c), + line(c, d), + [d, [clearance, 1.1], [-0.3, 1.5], e], + line(e, f), + line(f, a), + ]; + let mut controls = vec![a]; + for span in spans { + controls.extend_from_slice(&span[1..]); + } + for p in &mut controls { + for i in 0..2 { + p[i] += offset[i]; + } + } + let mut knots = vec![0.0; 4]; + for i in 1..6 { + knots.extend([i as f64; 3]); + } + knots.extend([6.0; 4]); + NurbsCurve::new(3, controls, knots, None).unwrap() +} + +fn edge(spline: NurbsCurve) -> BoundaryEdge { + BoundaryEdge::Spline(SplineEdge { + degree: spline.degree() as i32, + rational: false, + periodic: false, + knots: spline.knots().to_vec(), + control_points: spline + .control_points() + .iter() + .map(|p| Vector3::new(p[0], p[1], 0.0)) + .collect(), + fit_points: Vec::new(), + start_tangent: Vector2::new(0.0, 0.0), + end_tangent: Vector2::new(0.0, 0.0), + }) +} + +fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath { + let points = [a, [b[0], a[1]], b, [a[0], b[1]]] + .map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1])); + let mut path = BoundaryPath::new(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new( + points.to_vec(), + true, + ))); + path +} + +#[test] +fn narrow_fill_preserves_corners_holes_and_shared_origin() { + let curve = narrow(0.00001, [0.0; 2]); + let area: f64 = (0..6) + .map(|i| { + Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap()) + .enclosed_area() + }) + .sum(); + for (offset, shuffled, multiple) in [ + ([0.0; 2], false, false), + ([1e6, -1e6], false, false), + ([0.0; 2], true, true), + ([1e6, -1e6], true, true), + ] { + let spline = narrow(0.00001, offset); + let mut path = BoundaryPath::new(); + path.edges = if shuffled { + let (a, b) = spline.split_at(0.5).unwrap(); + vec![edge(b), edge(a.reversed())] + } else { + vec![edge(spline.clone())] + }; + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + if multiple { + hatch.paths.extend([ + rectangle([-0.8, -0.8], [-0.2, -0.2], offset), + rectangle([-0.6, -0.6], [-0.4, -0.4], offset), + rectangle([1.0, -1.0], [2.0, 2.5], offset), + ]); + } + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + if multiple { + assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]); + } + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }; + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let original: Vec<_> = original + .into_iter() + .map(|p| project(p).map(f64::from)) + .collect(); + assert!(triangulate_rings(&[original]).1.is_empty()); + for i in 0..=6 { + assert!(model + .boundary + .contains(&project(spline.point_at(i as f64 / 6.0)))); + } + assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths); + let (vertices, indices) = model.fill_mesh(); + assert!(!indices.is_empty()); + let mut filled_area = 0.0; + for triangle in indices.chunks_exact(3) { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]] + .map(|i| vertices[i as usize].map(f64::from)); + let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + assert!(twice_area > 0.0); + filled_area += twice_area * 0.5; + if multiple { + let p = + [0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]); + let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi); + assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4)); + } + } + let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 }; + assert!( + (filled_area - expected).abs() < 0.001, + "{filled_area} vs {expected}" + ); + } +} + +#[test] +fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { + for clearance in [0.01, 0.000001, 0.000002, 0.000005] { + let spline = narrow(clearance, [0.0; 2]); + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let expected: Vec<_> = original + .iter() + .map(|p| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }) + .collect(); + assert_eq!(model.boundary.as_ref(), &expected); + assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01); + } + let curve = Curve::Nurbs(narrow(0.01, [0.0; 2])); + assert!(refine_spline_boundary( + &curve.tessellate_angle(DEFAULT_ANGLE), + || panic!("valid path resampled"), + |p| p + ) + .is_none()); + assert!(refine_spline_boundary( + &vec![[0.0; 2]; 513], + || panic!("size cap ignored"), + |p| p + ) + .is_none()); +} + +#[test] +fn recovery_respects_the_total_hatch_vertex_cap() { + let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2])); + let original = curve.tessellate_angle(DEFAULT_ANGLE); + let Curve::Nurbs(spline) = curve.clone() else { + unreachable!() + }; + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let count = 16_384 - original.len() - 2; + let points = (0..count) + .map(|i| { + let angle = std::f64::consts::TAU * i as f64 / count as f64; + Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin()) + }) + .collect(); + let mut other = BoundaryPath::new(); + other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true))); + hatch.paths.push(other); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32 as f64, + (p[1] - model.world_origin[1]) as f32 as f64, + ] + }; + assert!( + refine_spline_boundary(&original, || vec![curve], project) + .unwrap() + .len() + > original.len() + ); + assert_eq!(model.boundary.len(), 16_384); + let expected: Vec<_> = original + .into_iter() + .map(|p| project(p).map(|v| v as f32)) + .collect(); + assert_eq!(&model.boundary[..expected.len()], expected.as_slice()); +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index db832be0c..384933efd 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -29,19 +29,20 @@ 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; +#[cfg(test)] +mod hatch_boundary; mod group_layer; mod layout; mod limits; mod modify; -pub mod named_parameters; -mod named_parameters_persist; mod mspace; pub mod viewport_ref; pub mod viewport_dim_seam; +pub mod named_parameters; mod page_setup; mod paper; mod preview; @@ -49,22 +50,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 @@ -84,9 +83,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; @@ -107,11 +114,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; @@ -122,9 +134,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 { @@ -336,15 +356,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, } @@ -359,21 +382,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 @@ -386,12 +412,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. @@ -899,14 +934,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 { @@ -920,8 +955,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). @@ -957,17 +991,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; @@ -1479,7 +1509,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), } } @@ -1629,6 +1662,9 @@ pub struct Scene { /// Currently selected entity handles. pub selected: HashSet, selected_order: Vec, + /// The parametric-constraint glyph currently selected in the viewport. + /// Entity selection changes clear it so the two selections stay exclusive. + pub selected_constraint: Option, /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. pub object_isolation: ObjectIsolationState, /// Entity handles temporarily removed from the base render while an @@ -1647,6 +1683,8 @@ pub struct Scene { /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, + /// All entities related to the constraint indicator currently under the cursor. + constraint_hover_highlights: HashSet, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -1803,10 +1841,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. @@ -1832,13 +1868,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). @@ -1926,16 +1958,20 @@ 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. + /// Constraint glyphs explicitly shown through the constraint-bar commands. + shown_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, + )>, + /// 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 @@ -2072,7 +2108,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)) } @@ -2102,14 +2142,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, )); @@ -2173,11 +2208,13 @@ impl Scene { lighting_cache: RefCell::new(HashMap::default()), selected: HashSet::default(), selected_order: Vec::new(), + selected_constraint: None, object_isolation: ObjectIsolationState::default(), preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, hover_highlight: None, + constraint_hover_highlights: HashSet::default(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -2250,8 +2287,9 @@ 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(), + shown_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()), @@ -2338,7 +2376,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; } @@ -2434,8 +2474,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 { @@ -2683,11 +2722,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); @@ -2696,22 +2731,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); } } } @@ -2814,6 +2849,33 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_policy(changes, &[], false); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { + self.bump_entities_with_parametric_policy(changes, driven_refs, false); + } + + pub fn bump_entities_with_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_parametric_originals(changes, driven_refs, retain_size, &[]); + } + + pub fn bump_entities_with_parametric_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2846,13 +2908,21 @@ 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_with_originals( + &changes, + driven_refs, + retain_size, + retained_originals, + ) { 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); } @@ -2906,10 +2976,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 @@ -3000,8 +3067,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). @@ -3161,15 +3232,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(); @@ -3213,12 +3283,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) { @@ -3246,18 +3325,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) @@ -3282,12 +3359,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( @@ -3328,21 +3405,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) }) @@ -3440,35 +3515,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 = @@ -4008,8 +4075,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; @@ -4218,9 +4284,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) @@ -4460,14 +4524,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 @@ -4544,14 +4604,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, + }) }) } @@ -4580,11 +4643,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 { @@ -4617,14 +4677,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 { @@ -4720,12 +4783,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, @@ -4861,16 +4922,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) } @@ -5050,9 +5118,20 @@ impl Scene { /// remains the picked member for hit-test/UI bookkeeping; rendering expands /// it to the same selectable group that a click would select. pub fn hover_highlight_handles(&self) -> HashSet { - self.hover_highlight + let mut handles = self + .hover_highlight .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) - .unwrap_or_default() + .unwrap_or_default(); + handles.extend(self.constraint_hover_highlights.iter().copied()); + handles + } + + pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { + let desired: HashSet<_> = handles.iter().copied().collect(); + if desired != self.constraint_hover_highlights { + self.constraint_hover_highlights = desired; + self.bump_selection(); + } } /// Keep the current selection visible and temporarily filter every other @@ -5173,11 +5252,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()) @@ -5189,18 +5274,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); } } @@ -5245,11 +5339,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()) } @@ -5259,11 +5354,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(); @@ -5308,18 +5399,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 { @@ -5596,7 +5680,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/ @@ -5633,10 +5719,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, @@ -5832,15 +5915,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 @@ -5849,9 +5929,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())) @@ -5896,12 +5974,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, @@ -5910,8 +5983,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, @@ -5927,15 +5999,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, @@ -5951,10 +6019,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)); @@ -6011,9 +6076,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; @@ -6027,10 +6090,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); @@ -6303,20 +6368,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 @@ -6463,7 +6523,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()); { @@ -6512,10 +6576,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 } @@ -6583,12 +6646,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))); @@ -6650,6 +6709,49 @@ impl Scene { all_visible: bool, viewport: Option, ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, false, |image, _| image) + } + + pub fn paper_plot_images(&self) -> Vec { + let scale = if self.current_layout == "Model" { + self.displayed_annotation_scale_handle() + } else { + self.paper_annotation_scale_handle() + }; + self.placed_images(self.current_layout_block_handle(), None, + scale, self.annotation_all_visible(), None, true) + } + + fn placed_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, plotting, |image, context| crate::io::pdf_export::PlotImage { + image, + clips: context.clips.clone(), + }) + } + + fn collect_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + mut collect: impl FnMut(ImageModel, &render_graph::InstanceContext) -> T, + ) -> Vec { + if self.images.is_empty() { + return Vec::new(); + } let depth_map = self.draw_depth_map(); let graph = render_graph::RenderSceneGraph::new( &self.document, @@ -6675,8 +6777,10 @@ impl Scene { graph.walk_root( root, |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + (!plotting || self.document.layers.get(&entity.common().layer) + .is_none_or(|layer| layer.is_plottable)) + && (context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle)) }, |entity, context| { let handle = entity.common().handle; @@ -6685,9 +6789,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, @@ -6708,11 +6810,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, @@ -6721,22 +6819,27 @@ 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); + models.push(collect(placed, context)); }, ); models @@ -6946,10 +7049,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() = @@ -7044,17 +7144,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; @@ -7063,11 +7161,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() @@ -7093,9 +7192,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 { @@ -7181,13 +7278,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); }; @@ -7210,8 +7302,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), @@ -7350,7 +7450,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); @@ -7382,8 +7486,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) { @@ -7391,12 +7495,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))); @@ -7552,8 +7651,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() { @@ -7565,8 +7663,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, @@ -7593,14 +7690,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() { @@ -7608,8 +7701,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, @@ -7624,10 +7716,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(); @@ -7636,31 +7725,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); @@ -7691,10 +7775,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 { @@ -7764,8 +7845,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 { @@ -7872,21 +7952,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); @@ -7943,20 +8019,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, @@ -7982,8 +8052,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 { @@ -8068,9 +8137,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 @@ -8106,21 +8174,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)); } } @@ -8193,8 +8253,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( @@ -8227,9 +8286,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, @@ -8314,11 +8372,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 { @@ -8327,13 +8382,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; @@ -8359,7 +8414,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 { @@ -8386,11 +8441,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( @@ -8429,7 +8481,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 { @@ -8445,25 +8497,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) }) } } @@ -8505,13 +8548,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(_)) + ) }, ) => { @@ -8708,8 +8751,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)) @@ -8925,8 +8967,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( @@ -9060,14 +9108,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 @@ -9091,12 +9132,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, @@ -9162,10 +9198,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(); @@ -9201,14 +9234,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( @@ -9608,7 +9634,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 { @@ -9729,7 +9758,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. @@ -9952,7 +9983,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() @@ -9987,9 +10021,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 @@ -10028,8 +10062,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()) } @@ -10161,9 +10194,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) } @@ -10208,8 +10239,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(); @@ -10381,10 +10411,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); } @@ -10451,7 +10478,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); } } @@ -10474,11 +10502,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()); } @@ -10732,7 +10756,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 @@ -10765,8 +10795,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( @@ -10868,11 +10897,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, @@ -10911,13 +10936,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; @@ -10942,15 +10962,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)); @@ -11081,9 +11111,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]); @@ -11099,20 +11128,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]); } @@ -11322,10 +11348,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] @@ -11380,9 +11403,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| { @@ -11471,14 +11494,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), @@ -11555,10 +11596,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)) }; @@ -11831,10 +11870,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); } @@ -11843,8 +11879,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) }) })); @@ -11974,15 +12009,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)); } } @@ -12148,7 +12189,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); @@ -12172,7 +12214,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); @@ -12224,7 +12267,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, @@ -12243,13 +12291,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] @@ -12259,13 +12322,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] @@ -12276,7 +12348,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() { @@ -12286,10 +12361,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" + ); } } @@ -12303,7 +12384,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 ); } @@ -12431,12 +12513,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] @@ -12487,7 +12593,7 @@ mod layout_cache_tests { let mut arc = acadrust::entities::Arc::default(); arc.radius = 25.0; arc.start_angle = 0.0; - arc.end_angle = 3.14159; + arc.end_angle = std::f64::consts::PI; let a_h = s.add_entity(EntityType::Arc(arc)); let line = acadrust::entities::Line::from_points( @@ -12497,13 +12603,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..6c3f2eb39 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,16 @@ 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 - /// color the constraint's glyph pill already uses. - EntityLink { handles: Vec, conflicting: bool }, + /// `ParametricConstraintSet::conflicts`, tinting the row the same danger + /// color the constraint's glyph pill already uses. `id` routes the row's + /// delete action back to that constraint in the active scope. + EntityLink { + id: crate::scene::parametric_constraints::ConstraintId, + 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 @@ -80,6 +85,11 @@ pub enum PropValue { }, /// The trailing "+ Add parameter" row in the Parameters section. ParamAddRow, + /// The Parameters section's leading header row (no-selection page only): + /// a global on/off toggle for whether any constraint pill in the + /// viewport shows its driven value/parameter-name text. Mirrors + /// `App::show_constraint_values`. + ParamsVisibilityToggle(bool), } /// A single property row in the Properties panel. 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/parametric_constraints.rs b/src/scene/parametric_constraints.rs new file mode 100644 index 000000000..082da8eb3 --- /dev/null +++ b/src/scene/parametric_constraints.rs @@ -0,0 +1,1983 @@ +//! Runtime parametric-constraint data and scope management. + +use super::named_parameters::DrivingValue; +use acadrust::types::{Handle, Vector3}; + +/// One endpoint a constraint attaches to: an entity plus which sub-element +/// of it. +/// +/// Reuses the GsMarker convention `AssocDimensionReference::main_gs_marker` +/// already carries for associative-dimension endpoints +/// (`src/scene/dimension_assoc.rs`), rather than inventing a second +/// sub-element addressing scheme: `marker` indexes into +/// [`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 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, + /// Vertical) needs; a point-level constraint (Coincident, Distance + /// between two points, Angle at a shared vertex) sets `Some(marker)`. + pub marker: Option, +} + +const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; +const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; + +impl ParametricRef { + pub fn whole(entity: Handle) -> Self { + Self { + entity, + marker: None, + } + } + + pub fn point(entity: Handle, marker: i32) -> Self { + Self { + entity, + marker: Some(marker), + } + } + + /// The circle/arc-center special case (`marker == -3`), broken out as + /// its own constructor since `-3` alone reads as a magic number + /// everywhere it would otherwise appear. + pub fn center(entity: Handle) -> Self { + Self { + entity, + marker: Some(-3), + } + } + + /// Select one straight segment of a polyline. + pub fn segment(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_MARKER_BASE - index as i32), + } + } + + pub fn segment_index(self) -> Option { + let marker = self.marker?; + (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) + } +} + +/// Point reference driven by a native entity grip. Segment midpoint and +/// scalar radius grips deliberately return None because they do not identify +/// one stored point parameter. +pub(crate) fn driven_ref_for_grip( + entity: &acadrust::EntityType, + handle: Handle, + grip_id: usize, +) -> Option { + match entity { + acadrust::EntityType::Line(_) if grip_id <= 1 => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Arc(_) => match grip_id { + 0 => Some(ParametricRef::center(handle)), + 1 => Some(ParametricRef::point(handle, 0)), + 2 => Some(ParametricRef::point(handle, 1)), + _ => None, + }, + acadrust::EntityType::Circle(_) if grip_id == 0 => { + Some(ParametricRef::center(handle)) + } + acadrust::EntityType::Point(_) + | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + if grip_id == 0 => + { + Some(ParametricRef::point(handle, 0)) + } + _ => None, + } +} + +/// The friendly, user-facing constraint types — the "what button did they +/// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps +/// onto (that mapping is `constraint_map`, a later stage; see the design +/// doc §2). Named and grouped the same way the existing one-shot ribbon +/// 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)] +pub enum ConstraintKind { + Coincident, + Horizontal, + Vertical, + Parallel, + Perpendicular, + Equal, + /// Distance between two points, or a single line's length, or a + /// circle/arc's diameter — which reading applies depends on `refs`' + /// shape, mirroring how `DistanceConstraintCommand` + /// (`src/modules/draw/constrain/value.rs`) already infers it from the + /// 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 bounded curve endpoint. + Smooth, + /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. + /// 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. + Concentric, + /// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`. + /// Same solver math as `Coincident`/`Concentric`, different DWG class + /// name (`ACCENTERPOINTCONSTRAINT`). + CenterPoint, + /// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`. + Colinear, + /// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`. + 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 `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)]`. + PointOnCurve, + /// The distance between one point pair equals the distance between + /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). + /// For the supported entity types, equal curvature is already covered + /// by the circle/circle radius branch of `Equal`. + EqualDistance, + /// Two circles/arcs are mirror images of each other across a line — + /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the + /// circle-center-pair case (unambiguous with whole-entity selection); + /// point-symmetry about a point, and symmetry between two lines, aren't + /// modeled. + Symmetric, + /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: + /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` + /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the + /// `RadiusDiameterConstrType` mode byte + /// (`dwg_native_constraints.rs`), rather than a distinct object type. + Diameter, + /// The X-only (resp. Y-only) component of the distance between two + /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG + /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its + /// `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 + /// passes through the circle's center" (a circle's radius is always + /// normal to its own tangent), so it solves via the same `PointOnLine` + /// primitive `PointOnCurve` already uses. Distinct from + /// `Perpendicular` (line-to-line only). Line-Line has no meaning here + /// (that's plain `Perpendicular`) and isn't buildable. + Normal, + /// 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; + +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)] +pub struct ParametricConstraint { + pub id: ConstraintId, + pub kind: ConstraintKind, + 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 + /// `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)] +pub enum ParametricScope { + ModelSpace, + /// A block definition's `BlockRecord` handle — matches + /// `BlockEditSession::br_handle` + /// (`src/modules/draw/modify/block_edit.rs`). + Block(Handle), +} + +impl ParametricScope { + /// The owner handle under which this scope is persisted. + pub fn owner_handle(&self, document: &acadrust::CadDocument) -> Handle { + match self { + ParametricScope::ModelSpace => document.header.model_space_block_handle, + ParametricScope::Block(handle) => *handle, + } + } +} + +/// Every constraint decoded from one standard graph scope. Solver state is rebuilt +/// on demand and is not stored here. +#[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 + /// `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. + 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. + pub conflicts: Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )>, +} + +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(), + } + } + + /// Appends a constraint, assigning it a fresh id unique within this set. + pub fn add( + &mut self, + kind: ConstraintKind, + refs: Vec, + driving_param: Option, + ) -> ConstraintId { + let id = self.next_id; + self.next_id += 1; + 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 + } + + /// Removes a constraint by id. Returns whether one was actually removed. + pub fn remove(&mut self, id: ConstraintId) -> bool { + let before = self.constraints.len(); + self.constraints.retain(|c| c.id != id); + self.constraints.len() != before + } + + 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 { + self.constraints + .iter() + .filter(move |c| c.enabled && c.refs.iter().any(|r| r.entity == entity)) + } + + /// Drops every constraint that references `entity` and returns the + /// removed constraint ids. + pub fn remove_all_touching(&mut self, entity: Handle) -> Vec { + let (removed, kept): (Vec<_>, Vec<_>) = self + .constraints + .drain(..) + .partition(|c| c.refs.iter().any(|r| r.entity == entity)); + self.constraints = kept; + removed.into_iter().map(|c| c.id).collect() + } +} + +/// 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, 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. +pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { + if marker == -3 { + return match entity { + acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), + acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()), + acadrust::EntityType::Ellipse(ellipse) => Some(ellipse.center), + _ => 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 planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let point = planar.plane.point_at(curve.point_at(0.5)); + return Some(Vector3::new(point[0], point[1], point[2])); + } + if marker < 0 { + return None; + } + super::dimension_assoc::source_points(entity) + .get(marker as usize) + .copied() +} + +/// Below this squared distance (1e-6 world units), two points count as +/// already coincident for [`nearest_parametric_point`]'s purposes. +const COINCIDENT_EPSILON_SQ: f64 = 1.0e-12; + +/// Addressable constraint points for one entity, in the same marker space +/// used by persistent constraint references. +pub(crate) fn parametric_point_candidates( + entity: &acadrust::EntityType, +) -> Vec<(i32, Vector3)> { + let mut points: Vec<_> = super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + .map(|(marker, point)| (marker as i32, point)) + .collect(); + match entity { + acadrust::EntityType::Circle(circle) => points.push((-3, circle.center_wcs())), + acadrust::EntityType::Arc(arc) => points.push((-3, arc.center_wcs())), + acadrust::EntityType::Ellipse(ellipse) => points.push((-3, ellipse.center)), + _ => {} + } + + if matches!( + entity, + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Spline(_) + | acadrust::EntityType::Ellipse(_) + ) { + if let Some(curve) = crate::entities::curve::entity_curve(entity) { + if !curve.is_closed() { + let point = curve.point_at(0.5); + points.push((-2, Vector3::new(point[0], point[1], point[2]))); + } + } + } + + if matches!( + entity, + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) + ) { + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + points.extend(planar.curve.segments().into_iter().enumerate().map( + |(index, curve)| { + let point = planar.plane.point_at(curve.point_at(0.5)); + ( + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - index as i32, + Vector3::new(point[0], point[1], point[2]), + ) + }, + )); + } + } + points +} + +pub(crate) fn is_parametric_point_near(entity: &acadrust::EntityType, point: Vector3) -> bool { + parametric_point_candidates(entity) + .into_iter() + .any(|(_, candidate)| (candidate - point).length_squared() <= COINCIDENT_EPSILON_SQ) +} + +/// 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_parametric_point( + document: &acadrust::CadDocument, + scope: ParametricScope, + world_point: Vector3, + exclude: Option, +) -> Option { + let owner = scope.owner_handle(document); + 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, ParametricRef::point(handle, marker))); + } + }; + for candidate in document.entities() { + let common = candidate.common(); + if common.owner_handle != owner || Some(common.handle) == exclude { + continue; + } + for (marker, point) in parametric_point_candidates(candidate) { + consider(common.handle, marker, point); + } + } + best.map(|(_, r)| r) +} + +/// Resolve a point pick within one explicitly selected entity. This keeps two +/// different endpoints at the same world coordinate distinguishable. +pub(crate) fn nearest_parametric_point_on_entity( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + parametric_point_candidates(entity) + .into_iter() + .filter_map(|(marker, point)| { + let distance = (point - world_point).length_squared(); + (distance <= COINCIDENT_EPSILON_SQ) + .then_some((distance, ParametricRef::point(handle, marker))) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)) + .map(|(_, reference)| reference) +} + +/// Resolve the whole curve or the picked polyline segment used by a +/// point-to-curve Coincident relation. +pub(crate) fn parametric_curve_ref_for_pick( + document: &acadrust::CadDocument, + scope: ParametricScope, + handle: Handle, + world_point: Vector3, +) -> Option { + let entity = document.get_entity(handle)?; + if entity.common().owner_handle != scope.owner_handle(document) { + return None; + } + match entity { + acadrust::EntityType::Line(_) + | acadrust::EntityType::Circle(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::Ellipse(_) + | acadrust::EntityType::Spline(_) => Some(ParametricRef::whole(handle)), + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) => { + let segments = crate::entities::curve::entity_curve_xy(entity)?.segments(); + cadkernel::geom2d::nearest_of(segments.iter(), [world_point.x, world_point.y]) + .map(|(index, _)| ParametricRef::segment(handle, index)) + } + _ => None, + } +} + +impl ConstraintKind { + /// The short symbol a constraint glyph shows — matches the existing + /// ribbon icons (`crate::modules::parametric::{tools,value}`) for + /// the kinds that have a one-click button, so the same glyph means the + /// same thing in both places. + pub fn glyph_symbol(&self) -> &'static str { + match self { + ConstraintKind::Coincident => "≡", + ConstraintKind::Horizontal => "—", + ConstraintKind::Vertical => "│", + ConstraintKind::Parallel => "∥", + ConstraintKind::Perpendicular => "⊥", + ConstraintKind::Equal => "=", + ConstraintKind::Distance => "↔", + ConstraintKind::Angle => "∠", + ConstraintKind::Angle3Point => "∠₃", + ConstraintKind::Radius => "R", + ConstraintKind::Tangent => "T", + ConstraintKind::Smooth => "G²", + ConstraintKind::Concentric => "◎", + ConstraintKind::CenterPoint => "⊕", + ConstraintKind::Colinear => "L", + ConstraintKind::Midpoint => "M", + ConstraintKind::Fixed => "F", + ConstraintKind::PointOnCurve => "∈", + ConstraintKind::EqualDistance => "≐", + ConstraintKind::Symmetric => "S", + ConstraintKind::Diameter => "⌀", + ConstraintKind::DistanceX => "↔ₓ", + ConstraintKind::DistanceY => "↔ᵧ", + ConstraintKind::DistanceDirected => "↗", + ConstraintKind::Normal => "⊾", + 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: &ParametricConstraint) -> String { + match (constraint.kind, &constraint.driving_param) { + (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.1}°", constraint.kind.glyph_symbol()) + } + (_, Some(DrivingValue::Literal(value))) => { + format!("{} {value:.2}", constraint.kind.glyph_symbol()) + } + // A named reference has no single resolved number to show without + // threading `ParameterTable` into every glyph-render call site + // (`src/ui/overlay.rs`) — showing the name itself is enough for now; + // stage 4's parameters panel is the natural place to reconsider this + // once a named `driving_param` can actually be authored through the + // UI (nothing can yet — this arm exists so the match is exhaustive + // and correct ahead of that UI, not because it's reachable today). + (_, Some(DrivingValue::Named(name))) => { + format!("{} {name}", constraint.kind.glyph_symbol()) + } + (_, None) => constraint.kind.glyph_symbol().to_string(), + } +} + +/// World-space anchor and outward direction for a constraint glyph. +pub(crate) fn glyph_placement( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Option<(Vector3, Vector3)> { + let r = constraint.refs.first()?; + let entity = document.get_entity(r.entity)?; + let line_midpoint = |line: &acadrust::entities::Line| { + Vector3::new( + (line.start.x + line.end.x) * 0.5, + (line.start.y + line.end.y) * 0.5, + (line.start.z + line.end.z) * 0.5, + ) + }; + let segment_normal = |start: Vector3, end: Vector3| { + let direction = Vector3::new(-(end.y - start.y), end.x - start.x, 0.0); + (direction.length_squared() > 1e-24) + .then_some(direction) + .unwrap_or(Vector3::UNIT_Y) + }; + let line_normal = |line: &acadrust::entities::Line| segment_normal(line.start, line.end); + if let Some(segment) = r.segment_index() { + let anchor = resolve_point( + entity, + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - segment as i32, + )?; + let [start, end] = constraint_segment_endpoints(document, *r)?; + return Some((anchor, segment_normal(start, end))); + } + match (entity, r.marker) { + (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); + Some((anchor, anchor - center)) + } + (acadrust::EntityType::Arc(arc), None | Some(-3)) => { + let center = arc.center_wcs(); + let anchor = arc.midpoint_wcs(); + Some((anchor, anchor - center)) + } + (acadrust::EntityType::Line(line), Some(marker)) => { + let anchor = resolve_point(entity, marker)?; + let direction = anchor - line_midpoint(line); + Some(( + anchor, + (direction.length_squared() > 1e-24) + .then_some(direction) + .unwrap_or_else(|| line_normal(line)), + )) + } + (acadrust::EntityType::Arc(arc), Some(marker)) => { + let anchor = resolve_point(entity, marker)?; + let direction = anchor - arc.center_wcs(); + Some(( + anchor, + (direction.length_squared() > 1e-24) + .then_some(direction) + .unwrap_or(Vector3::UNIT_Y), + )) + } + (_, Some(marker)) => { + let anchor = resolve_point(entity, marker)?; + Some((anchor, Vector3::UNIT_Y)) + } + _ => None, + } +} + +/// World-space locations that explain what a hovered constraint acts on. +/// Point constraints expose their referenced point directly; curve relations +/// expose the contact or intersection that makes the relation visible. +fn constraint_segment_endpoints( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<[Vector3; 2]> { + let segment = reference.segment_index()?; + let entity = document.get_entity(reference.entity)?; + 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 first = *points.get(segment)?; + let second = if segment + 1 < points.len() { + points[segment + 1] + } else if closed { + *points.first()? + } else { + return None; + }; + Some([first, second]) +} + +fn constraint_reference_curve_xy( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option { + let curve = crate::entities::curve::entity_curve_xy(document.get_entity(reference.entity)?)?; + reference + .segment_index() + .map(|index| curve.segments().into_iter().nth(index)) + .unwrap_or(Some(curve)) +} + +pub(crate) fn constraint_hover_points( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec { + let mut points = Vec::new(); + let push_unique = |points: &mut Vec, point: Vector3| { + if point.x.is_finite() + && point.y.is_finite() + && point.z.is_finite() + && !points + .iter() + .any(|existing| (*existing - point).length_squared() <= 1.0e-12) + { + points.push(point); + } + }; + + for reference in &constraint.refs { + let Some(marker) = reference.marker else { + continue; + }; + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(point) = resolve_point(entity, marker) { + push_unique(&mut points, point); + } + } + + if matches!( + constraint.kind, + ConstraintKind::Horizontal | ConstraintKind::Vertical + ) { + for reference in &constraint.refs { + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + if let Some(endpoints) = constraint_segment_endpoints(document, *reference) { + for point in endpoints { + push_unique(&mut points, point); + } + } else if matches!(entity, acadrust::EntityType::Line(_)) { + for point in super::dimension_assoc::source_points(entity) { + push_unique(&mut points, point); + } + } + } + } + + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve)) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + ) { + let elevation = glyph_placement(document, constraint) + .map(|(anchor, _)| anchor.z) + .unwrap_or(0.0); + for crossing in cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) { + push_unique( + &mut points, + Vector3::new(crossing.point[0], crossing.point[1], elevation), + ); + } + } + } + } + + points +} + +impl super::Scene { + /// Expand a set of entities to the full enabled constraint component in + /// one scope. Whole-object translations use this to keep every connected + /// entity's own shape while moving the assembly. + pub(crate) fn parametric_connected_handles( + &self, + scope: ParametricScope, + seeds: &[Handle], + ) -> Vec { + let mut ordered = seeds.to_vec(); + let mut found: std::collections::HashSet<_> = seeds.iter().copied().collect(); + let Some(set) = self.parametric_constraint_set(scope) else { + return ordered; + }; + loop { + let mut added = false; + for constraint in set.constraints.iter().filter(|constraint| { + constraint.enabled + && matches!( + constraint.kind, + ConstraintKind::Coincident | ConstraintKind::PointOnCurve + ) + }) { + if !constraint + .refs + .iter() + .any(|reference| found.contains(&reference.entity)) + { + continue; + } + for reference in &constraint.refs { + if found.insert(reference.entity) { + ordered.push(reference.entity); + added = true; + } + } + } + if !added { + break; + } + } + ordered + } + + pub fn is_parametric_constraint_visible( + &self, + scope: ParametricScope, + id: ConstraintId, + ) -> bool { + !self.hidden_parametric_constraints.contains(&(scope, id)) + } + + pub fn should_display_parametric_constraint( + &self, + scope: ParametricScope, + id: ConstraintId, + related_entity_selected: bool, + display_mode: i16, + ) -> bool { + self.is_parametric_constraint_visible(scope, id) + && (self.shown_parametric_constraints.contains(&(scope, id)) + || display_mode & 1 != 0 + || (display_mode & 2 != 0 && related_entity_selected)) + } + + pub fn set_parametric_constraint_visibility( + &mut self, + scope: ParametricScope, + handles: Option<&[Handle]>, + dimensional: bool, + visible: bool, + ) -> usize { + 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(); + for id in &ids { + if visible { + self.hidden_parametric_constraints.remove(&(scope, *id)); + self.shown_parametric_constraints.insert((scope, *id)); + } else { + self.hidden_parametric_constraints.insert((scope, *id)); + self.shown_parametric_constraints.remove(&(scope, *id)); + } + } + ids.len() + } + + /// Infers relations already present in the selected geometry. + pub fn inferred_parametric_constraints( + &self, + scope: ParametricScope, + handles: &[Handle], + settings: &crate::app::settings::AutoConstrainSettings, + ) -> Vec<(ConstraintKind, Vec)> { + use cadkernel::geom2d::{ + infer_constraints_with_settings, Arc, Circle, ConstraintEndpoint, InferenceKind, + InferenceSettings, InferredConstraint, Line, ParametricPrimitive, + }; + struct Source { + handle: Handle, + primitive: ParametricPrimitive, + whole: ParametricRef, + endpoints: [ParametricRef; 2], + } + let mut sources = Vec::new(); + for handle in handles { + match self.document.get_entity(*handle) { + Some(acadrust::EntityType::Line(line)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], + }), + Some(acadrust::EntityType::Circle(circle)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::center(*handle), + ParametricRef::center(*handle), + ], + }), + Some(acadrust::EntityType::Arc(arc)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], + }), + Some(acadrust::EntityType::LwPolyline(polyline)) => { + let Some(world) = crate::entities::curve::lwpolyline_world_xy(polyline) else { + continue; + }; + let count = world.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !world.is_closed { + break; + } + if world.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = world.vertices[index].location; + let b = world.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } + Some(acadrust::EntityType::Polyline2D(polyline)) => { + let count = polyline.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !polyline.is_closed() { + break; + } + if polyline.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } + _ => continue, + } + } + let primitives: Vec<_> = sources.iter().map(|source| source.primitive).collect(); + let marker = |endpoint| match endpoint { + ConstraintEndpoint::Start => 0usize, + ConstraintEndpoint::End => 1usize, + }; + let kind = |kind| match kind { + crate::app::settings::AutoConstraintKind::Coincident => InferenceKind::Coincident, + crate::app::settings::AutoConstraintKind::Collinear => InferenceKind::Collinear, + crate::app::settings::AutoConstraintKind::Parallel => InferenceKind::Parallel, + crate::app::settings::AutoConstraintKind::Perpendicular => { + InferenceKind::Perpendicular + } + crate::app::settings::AutoConstraintKind::Tangent => InferenceKind::Tangent, + crate::app::settings::AutoConstraintKind::Concentric => InferenceKind::Concentric, + crate::app::settings::AutoConstraintKind::Horizontal => InferenceKind::Horizontal, + crate::app::settings::AutoConstraintKind::Vertical => InferenceKind::Vertical, + crate::app::settings::AutoConstraintKind::Equal => InferenceKind::Equal, + }; + let inference_settings = InferenceSettings { + priority: settings + .priority + .iter() + .copied() + .filter(|candidate| settings.enabled.contains(candidate)) + .map(kind) + .collect(), + distance_tolerance: settings.distance_tolerance, + angle_tolerance_radians: settings.angle_tolerance_deg.to_radians(), + tangent_must_share_point: settings.tangent_must_share_point, + perpendicular_must_intersect: settings.perpendicular_must_intersect, + }; + let mut mapped: Vec<_> = + infer_constraints_with_settings(&primitives, &inference_settings) + .into_iter() + .filter(|relation| { + !matches!(relation, InferredConstraint::Coincident { first, second, .. } + if sources[*first].handle == sources[*second].handle) + }) + .map(|relation| match relation { + InferredConstraint::Coincident { + first, + first_endpoint, + second, + second_endpoint, + } => ( + ConstraintKind::Coincident, + vec![ + sources[first].endpoints[marker(first_endpoint)], + sources[second].endpoints[marker(second_endpoint)], + ], + ), + InferredConstraint::Collinear { first, second } => ( + ConstraintKind::Colinear, + vec![ + sources[first].whole, + sources[second].whole, + ], + ), + InferredConstraint::Concentric { first, second } => ( + ConstraintKind::Concentric, + vec![ + ParametricRef::center(sources[first].handle), + ParametricRef::center(sources[second].handle), + ], + ), + InferredConstraint::Parallel { first, second } => ( + ConstraintKind::Parallel, + vec![ + sources[first].whole, + sources[second].whole, + ], + ), + InferredConstraint::Perpendicular { first, second } => ( + ConstraintKind::Perpendicular, + vec![ + sources[first].whole, + sources[second].whole, + ], + ), + InferredConstraint::Horizontal { entity } => ( + ConstraintKind::Horizontal, + vec![sources[entity].whole], + ), + InferredConstraint::Vertical { entity } => ( + ConstraintKind::Vertical, + vec![sources[entity].whole], + ), + InferredConstraint::Tangent { first, second } => ( + ConstraintKind::Tangent, + vec![ + sources[first].whole, + sources[second].whole, + ], + ), + InferredConstraint::Equal { first, second } => ( + ConstraintKind::Equal, + vec![sources[first].whole, sources[second].whole], + ), + }) + .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_parametric_constraint(*kind, refs, None) + .is_ok() + }); + mapped + } + + /// Infer only endpoint/vertex coincidences from the selected objects. + /// Unlike the broader automatic constraint pass, this includes spline, + /// ellipse, and polyline vertices while leaving centers and midpoints to + /// their dedicated constraint kinds. + pub fn inferred_coincident_constraints( + &self, + scope: ParametricScope, + handles: &[Handle], + ) -> Vec> { + let owner = scope.owner_handle(&self.document); + let mut sources = Vec::new(); + let mut seen_handles = std::collections::HashSet::new(); + for handle in handles.iter().copied() { + if !seen_handles.insert(handle) { + continue; + } + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + if entity.common().owner_handle != owner { + continue; + } + for (marker, point) in super::dimension_assoc::source_points(entity) + .into_iter() + .enumerate() + { + sources.push((ParametricRef::point(handle, marker as i32), point)); + } + } + + let existing = self.parametric_constraint_set(scope); + let mut inferred = Vec::new(); + for first in 0..sources.len() { + for second in first + 1..sources.len() { + if sources[first].0.entity == sources[second].0.entity + || (sources[first].1 - sources[second].1).length_squared() + > COINCIDENT_EPSILON_SQ + { + continue; + } + let refs = vec![sources[first].0, sources[second].0]; + let already_exists = existing.is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.kind == ConstraintKind::Coincident + && constraint.refs.len() == 2 + && (constraint.refs == refs + || (constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0])) + }) + }); + if !already_exists + && self + .validate_parametric_constraint(ConstraintKind::Coincident, &refs, None) + .is_ok() + { + inferred.push(refs); + } + } + } + inferred + } + + 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) { + (acadrust::EntityType::Spline(spline), target) => (*first, spline, *second, target), + (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())); + 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()?]; + let mut best = (f64::INFINITY, 0, 0); + 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); + } + } + } + Some(vec![ + 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 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)` `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 parametric_constraint_set_mut( + &mut self, + scope: ParametricScope, + ) -> &mut ParametricConstraintSet { + if let Some(index) = self + .parametric_constraints + .iter() + .position(|s| s.scope == scope) + { + &mut self.parametric_constraints[index] + } else { + self.parametric_constraints + .push(ParametricConstraintSet::new(scope)); + self.parametric_constraints.last_mut().expect("just pushed") + } + } + + /// Screen-projected parametric-constraint glyph placements for `scope`: + /// `(id, anchor, outward_screen_direction, label, is_conflicting, + /// hover_points)` for every enabled, visible constraint whose glyph + /// projects on-screen. + /// `vp_size` is the full canvas size (as `SelectionState::vp_size` + /// reports it), matching what `viewport_edit_frame`/ + /// `active_model_tile_bounds` expect. Mirrors the projection + /// `crate::app::view` builds its own render list with, and is reused by + /// [`constraint_glyph_hit`](Self::constraint_glyph_hit) — both feed the + /// same `(anchor, outward, label)` triples into + /// `crate::ui::overlay::constraint_glyph_box`/`constraint_glyph_offsets`, + /// so hit-testing can never drift from what's actually drawn. + pub fn constraint_glyph_placements_screen( + &self, + scope: ParametricScope, + vp_size: (f32, f32), + show_values: bool, + display_mode: i16, + ) -> Vec<( + ConstraintId, + iced::Point, + [f32; 2], + String, + bool, + Vec, + )> { + let Some(set) = self.parametric_constraint_set(scope) else { + return Vec::new(); + }; + if set.constraints.is_empty() { + return Vec::new(); + } + let edit_frame = self.viewport_edit_frame(vp_size); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => self.active_model_tile_bounds(vp_size.0, vp_size.1), + }; + let (view_rot, eye) = if let Some((cam, _)) = &edit_frame { + (cam.view_proj_rte(bounds), cam.eye()) + } else { + let cam = self.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + set.constraints + .iter() + .filter(|c| c.enabled) + .filter(|c| { + let selected = c + .refs + .iter() + .any(|reference| self.selected.contains(&reference.entity)); + self.should_display_parametric_constraint(scope, c.id, selected, display_mode) + }) + .filter_map(|c| { + let (anchor, outward) = glyph_placement(&self.document, c)?; + let screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + )?; + let outward_screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + 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 is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); + let label = if show_values { + glyph_label(c) + } else { + c.kind.glyph_symbol().to_string() + }; + let hover_points = constraint_hover_points(&self.document, c) + .into_iter() + .filter_map(|hover_point| { + let projected = crate::scene::pick::grip::project_rte( + glam::DVec3::new(hover_point.x, hover_point.y, hover_point.z), + view_rot, + eye, + bounds, + )?; + let point = iced::Point::new( + bounds.x + projected.x, + bounds.y + projected.y, + ); + (point.x.is_finite() && point.y.is_finite()).then_some(point) + }) + .collect(); + point.x.is_finite().then_some(( + c.id, + point, + direction.to_array(), + label, + is_conflicting, + hover_points, + )) + }) + .collect() + } + + /// Hit-tests screen point `p` (same coordinate space as `p_full` in the + /// viewport click handler) against the glyph pills from + /// [`constraint_glyph_placements_screen`](Self::constraint_glyph_placements_screen), + /// via `crate::ui::overlay::constraint_glyph_hit_test`'s shared layout + /// math, so a click only registers where the pill is actually drawn. + pub fn constraint_glyph_hit( + &self, + scope: ParametricScope, + vp_size: (f32, f32), + show_values: bool, + display_mode: i16, + p: iced::Point, + ) -> Option { + let placements = self.constraint_glyph_placements_screen( + scope, + vp_size, + show_values, + display_mode, + ); + let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements + .iter() + .map(|(_, point, direction, label, is_conflicting, _)| { + (*point, *direction, label.clone(), *is_conflicting) + }) + .collect(); + let index = crate::ui::overlay::constraint_glyph_hit_test(&glyphs, p)?; + Some(placements[index].0) + } + + /// Handle remapping lives in each command that duplicates + /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, + /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own + /// (clipboard paste, `src/app/command_driver.rs`) — rather than in one + /// shared table, so each call site passes its own `handle_map` here + /// after adding the duplicated entities. + /// + /// For every enabled constraint whose *every* referenced entity was + /// duplicated (a constraint straddling a duplicated and a + /// non-duplicated entity can't sensibly follow — only one side moved), + /// adds an equivalent constraint over the new handles to the same + /// 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_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.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 + .refs + .iter() + .map(|r| ParametricRef { + entity: handle_map[&r.entity], + marker: r.marker, + }) + .collect(); + to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); + } + } + if to_add.is_empty() { + return; + } + 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.parametric_constraints[scope_index].add(kind, refs, driving_param); + } + touched.sort(); + touched.dedup(); + let changes: Vec<(Handle, super::ChangeKind)> = touched + .into_iter() + .map(|h| (h, super::ChangeKind::Modified)) + .collect(); + self.bump_entities(&changes); + } + + /// Every persistent constraint, in any scope, currently driven by the + /// named parameter `name` — what the Named Parameters panel's "used by" + /// column shows. `entities` is the constraint's own referenced handles + /// (deduplicated; a two-point constraint on the same entity's own two + /// markers would otherwise list it twice), not resolved against the + /// live document — a caller wanting an entity's current type/position + /// still needs `Scene::document.get_entity`. + pub fn parameter_usage(&self, name: &str) -> Vec { + let mut out = Vec::new(); + for set in &self.parametric_constraints { + for c in &set.constraints { + let Some(DrivingValue::Named(n)) = &c.driving_param else { + continue; + }; + if n != name { + continue; + } + let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); + entities.sort(); + entities.dedup(); + out.push(ParameterUsage { + scope: set.scope, + constraint_id: c.id, + kind: c.kind, + entities, + }); + } + } + out + } +} + +/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s +/// result type. +#[derive(Debug, Clone)] +pub struct ParameterUsage { + pub scope: ParametricScope, + pub constraint_id: ConstraintId, + pub kind: ConstraintKind, + pub entities: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(v: u64) -> Handle { + Handle::new(v) + } + + #[test] + fn glyph_placement_points_away_from_its_geometry() { + let mut document = acadrust::CadDocument::new(); + let mut line = acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ); + line.common.handle = h(1); + document + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let mut circle = acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 5.0); + circle.common.handle = h(2); + document + .add_entity(acadrust::EntityType::Circle(circle)) + .unwrap(); + let mut polyline = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(10.0, 0.0), + ]); + polyline.common.handle = h(3); + document + .add_entity(acadrust::EntityType::LwPolyline(polyline)) + .unwrap(); + + 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(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(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(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)); + let (anchor, direction) = + glyph_placement(&document, &constraint(ParametricRef::segment(h(3), 0))).unwrap(); + assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); + assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); + } + + #[test] + fn add_assigns_increasing_ids_and_get_finds_them() { + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + let a = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Distance, + vec![ParametricRef::whole(h(1))], + Some(DrivingValue::Literal(25.0)), + ); + assert_ne!(a, b); + assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); + assert_eq!( + set.get(b).unwrap().driving_param, + Some(DrivingValue::Literal(25.0)) + ); + } + + #[test] + fn remove_drops_only_the_matching_id() { + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + let a = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); + let b = set.add( + ConstraintKind::Vertical, + vec![ParametricRef::whole(h(2))], + None, + ); + assert!(set.remove(a)); + assert!( + !set.remove(a), + "removing twice should report nothing removed the second time" + ); + assert!(set.get(a).is_none()); + assert!(set.get(b).is_some()); + } + + #[test] + fn constraints_touching_finds_entity_regardless_of_marker() { + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Coincident, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], + None, + ); + set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(3))], + None, + ); + + let touching_1: Vec<_> = set.constraints_touching(h(1)).collect(); + assert_eq!(touching_1.len(), 1); + let touching_3: Vec<_> = set.constraints_touching(h(3)).collect(); + assert_eq!(touching_3.len(), 1); + assert_eq!(set.constraints_touching(h(99)).count(), 0); + } + + #[test] + fn constraints_touching_skips_disabled() { + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + let id = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); + set.constraints + .iter_mut() + .find(|c| c.id == id) + .unwrap() + .enabled = false; + assert_eq!(set.constraints_touching(h(1)).count(), 0); + } + + #[test] + fn remove_all_touching_drops_every_constraint_referencing_the_entity() { + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + let coincident = set.add( + ConstraintKind::Coincident, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 1)], + None, + ); + let horizontal_other = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(3))], + None, + ); + + let removed = set.remove_all_touching(h(1)); + assert_eq!(removed, vec![coincident]); + assert!(set.get(coincident).is_none()); + assert!( + set.get(horizontal_other).is_some(), + "unrelated entity's constraint must survive" + ); + } + + #[test] + fn scope_owner_handle_resolves_block_directly() { + let block_handle = h(42); + 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 = ParametricRef::center(h(7)); + assert_eq!( + r, + ParametricRef { + entity: h(7), + marker: Some(-3) + } + ); + } + + #[test] + fn arc_grips_drive_center_start_and_end_but_not_midpoint() { + let handle = h(8); + let arc = acadrust::EntityType::Arc(acadrust::entities::Arc::from_coords( + 0.0, + 0.0, + 0.0, + 5.0, + 0.0, + std::f64::consts::PI, + )); + + assert_eq!( + driven_ref_for_grip(&arc, handle, 0), + Some(ParametricRef::center(handle)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 1), + Some(ParametricRef::point(handle, 0)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 2), + Some(ParametricRef::point(handle, 1)) + ); + assert_eq!(driven_ref_for_grip(&arc, handle, 3), None); + } + + #[test] + fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { + let mut scene = super::super::Scene::new(); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], + Some(DrivingValue::Named("gap".to_string())), + ); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Radius, + 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 + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Radius, + vec![ParametricRef::whole(h(3))], + Some(DrivingValue::Literal(5.0)), + ); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![ParametricRef::point(h(4), 0), ParametricRef::point(h(4), 1)], + Some(DrivingValue::Named("other".to_string())), + ); + + let usage = scene.parameter_usage("gap"); + assert_eq!( + usage.len(), + 2, + "exactly the two constraints driven by 'gap', got {usage:?}" + ); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); + assert!(usage + .iter() + .any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); + + assert_eq!(scene.parameter_usage("nonexistent").len(), 0); + } + + #[test] + fn parameter_usage_searches_every_scope_not_just_model_space() { + let mut scene = super::super::Scene::new(); + let block = h(99); + scene + .parametric_constraint_set_mut(ParametricScope::Block(block)) + .add( + ConstraintKind::Radius, + 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, ParametricScope::Block(block)); + } + + #[test] + fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() { + let mut scene = super::super::Scene::new(); + // A Distance constraint whose two points are both on the same + // entity (e.g. a line's own start and end) must list that entity + // once, not twice. + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Distance, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(1), 1)], + Some(DrivingValue::Named("len".to_string())), + ); + let usage = scene.parameter_usage("len"); + assert_eq!(usage.len(), 1); + assert_eq!(usage[0].entities, vec![h(1)]); + } + + #[test] + fn automatic_inference_maps_relations_and_skips_existing_constraints() { + let mut scene = super::super::Scene::new(); + let first = scene.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(5.0, 0.0, 0.0), + ), + )); + let second = scene.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + Vector3::new(5.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ), + )); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(first)], + None, + ); + + let inferred = + scene.inferred_parametric_constraints( + ParametricScope::ModelSpace, + &[first, second], + &crate::app::settings::AutoConstrainSettings::default(), + ); + + assert!(!inferred.iter().any(|(kind, refs)| { + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] + })); + assert!(inferred.iter().any(|(kind, refs)| { + *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(second)] + })); + assert!(inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Coincident)); + assert!(!inferred + .iter() + .any(|(kind, _)| *kind == ConstraintKind::Colinear)); + } + + #[test] + fn visibility_toggles_geometric_and_dimensional_independently() { + let mut scene = super::super::Scene::new(); + let line = scene.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(5.0, 0.0, 0.0), + ), + )); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + let geometric = set.add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(line)], + None, + ); + let dimensional = set.add( + ConstraintKind::Distance, + vec![ParametricRef::point(line, 0), ParametricRef::point(line, 1)], + Some(DrivingValue::Literal(5.0)), + ); + + assert_eq!( + scene.set_parametric_constraint_visibility( + ParametricScope::ModelSpace, + None, + false, + false, + ), + 1 + ); + assert!(!scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, geometric)); + assert!(scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, dimensional)); + } + + #[test] + fn hidden_constraint_stays_hidden_when_related_entity_is_selected() { + let mut scene = super::super::Scene::new(); + let scope = ParametricScope::ModelSpace; + let id = scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); + scene.hidden_parametric_constraints.insert((scope, id)); + + assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); + } + + #[test] + fn hover_geometry_keeps_a_bulged_polyline_segment_curved() { + let mut document = acadrust::CadDocument::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.common.handle = h(1); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + document + .add_entity(acadrust::EntityType::LwPolyline(polyline)) + .unwrap(); + + assert!(matches!( + constraint_reference_curve_xy(&document, ParametricRef::segment(h(1), 0)), + Some(cadkernel::geom2d::Curve::Arc(_)) + )); + } + + #[test] + fn curve_pick_uses_the_bulged_segment_instead_of_its_chord() { + let mut scene = super::super::Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + acadrust::entities::LwVertex::from_coords(0.0, -4.0), + ]; + let handle = scene.add_entity(acadrust::EntityType::LwPolyline(polyline)); + + assert_eq!( + parametric_curve_ref_for_pick( + &scene.document, + ParametricScope::ModelSpace, + handle, + Vector3::new(5.0, -5.0, 0.0), + ), + Some(ParametricRef::segment(handle, 0)) + ); + } + + #[test] + fn move_expansion_follows_only_coincident_relations() { + let mut scene = super::super::Scene::new(); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Coincident, + vec![ParametricRef::point(h(1), 0), ParametricRef::point(h(2), 0)], + None, + ); + set.add( + ConstraintKind::PointOnCurve, + vec![ParametricRef::point(h(2), 0), ParametricRef::whole(h(3))], + None, + ); + set.add( + ConstraintKind::Parallel, + vec![ParametricRef::whole(h(3)), ParametricRef::whole(h(4))], + None, + ); + + assert_eq!( + scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)]), + vec![h(1), h(2), h(3)] + ); + } +} diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs new file mode 100644 index 000000000..535faf3b1 --- /dev/null +++ b/src/scene/parametric_solve.rs @@ -0,0 +1,3154 @@ +//! 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, 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::bspline::{Coord as SplineCoord, PointOnBSpline}; +use cadkernel_constraints::constraints::circle_arc::{C2LDistance, P2CDistance, TangentCircumf}; +use cadkernel_constraints::constraints::conic::{ + EqualMajorAxesConic, PointOnEllipse, TangentEllipseLine, +}; +use cadkernel_constraints::constraints::curve_generic::{BoundedArcValue, 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, 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::solvers::lm::solve_lm; +use cadkernel_constraints::solvers::SolveStatus; +use cadkernel_constraints::system::System; + +use super::named_parameters::ParameterTable; +use super::parametric_constraints::{ + angle_sector, distance_direction_type, ConstraintId, ConstraintKind, ParametricConstraint, + ParametricConstraintSet, ParametricRef, +}; +use super::{ChangeKind, Scene}; + +/// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s +/// parameter store. +#[derive(Clone)] +enum EntityGeom { + Point(GPoint), + Line(GLine), + Ray(GLine), + XLine(GLine), + Polyline { + points: Rc>, + straight: Rc>, + arcs: Rc>>, + closed: bool, + }, + Circle(GCircle), + /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ + /// `end_angle`, and real `start`/`end` points kept consistent with + /// those by the arc-rules `CurveValue` constraints `solve_scope` adds + /// for every registered arc. See this module's doc comment. + Arc(GArc), + /// See `register_entity` for the entity-to-kernel parametrization. + Ellipse(GEllipse), + Spline { + curve: GBSpline, + control_z: Rc>, + }, +} + +#[derive(Clone, Copy)] +struct PolylineArc { + arc: GArc, + sweep: f64, +} + +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 + /// `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() + } + (EntityGeom::Circle(c), -3) => Some(c.center), + (EntityGeom::Arc(a), 0) => Some(a.start), + (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, + } + } + + fn line_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { + points, + straight, + closed, + .. + } = self + else { + return None; + }; + if !straight.get(index).copied().unwrap_or(false) { + return None; + } + let p1 = *points.get(index)?; + let p2 = if index + 1 < points.len() { + points[index + 1] + } else if *closed { + *points.first()? + } else { + return None; + }; + Some(GLine { p1, p2 }) + } + + fn arc_segment(&self, index: usize) -> Option { + let EntityGeom::Polyline { arcs, .. } = self else { + return None; + }; + arcs.get(index).copied().flatten() + } +} + +/// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or +/// "a whole line" — an `Arc` collapses to its `.circle` here exactly like +/// `whole_circle` does, so those two constraints' own inline matches don't +/// need every Circle/Arc combination spelled out separately. +enum CircleOrLine { + Circle(GCircle), + Line(GLine), + Ellipse(GEllipse), + Other, +} + +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) | 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) + .map(CircleOrLine::Line) + .or_else(|| { + g.arc_segment(index) + .map(|segment| CircleOrLine::Circle(segment.arc.circle)) + }) + }) + .unwrap_or(CircleOrLine::Other), + } +} + +/// Reads `handle`'s live geometry and registers it as fresh, free (never +/// `driven`) parameters in `sys` — every referenced entity is solved for, +/// nothing is a priori fixed; per the 2D kernel, an under- +/// constrained scope simply converges to the nearest configuration to its +/// current one, which is the correct behavior for a persistent system where +/// "what stays put" is a property of how many constraints exist, not a +/// convention about which entity was picked first (contrast the one-shot +/// `constrain::apply_*` commands, which do fix the first-picked entity — +/// they solve exactly one constraint in isolation, so they need that +/// convention; a whole scope's constraint graph does not). +fn register_entity( + document: &acadrust::CadDocument, + sys: &mut System, + handle: Handle, +) -> Option { + let entity = document.get_entity(handle)?; + match entity { + EntityType::Point(point) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(point.location.x, false), + sys.add_param(point.location.y, false), + ))), + EntityType::Insert(insert) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(insert.insert_point.x, false), + sys.add_param(insert.insert_point.y, false), + ))), + EntityType::Text(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::MText(text) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(text.insertion_point.x, false), + sys.add_param(text.insertion_point.y, false), + ))), + EntityType::AttributeDefinition(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::AttributeEntity(attribute) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(attribute.insertion_point.x, false), + sys.add_param(attribute.insertion_point.y, false), + ))), + EntityType::Table(table) => Some(EntityGeom::Point(GPoint::new( + sys.add_param(table.insertion_point.x, false), + sys.add_param(table.insertion_point.y, false), + ))), + EntityType::LwPolyline(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, + }) + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed, + }) + } + EntityType::Polyline2D(polyline) => { + let source = super::dimension_assoc::source_points(entity); + let points: Vec<_> = source + .iter() + .map(|point| { + GPoint::new(sys.add_param(point.x, false), sys.add_param(point.y, false)) + }) + .collect(); + let arcs = (0..polyline.vertices.len()) + .map(|index| { + let next = if index + 1 < source.len() { + index + 1 + } else if polyline.is_closed() { + 0 + } else { + return None; + }; + let bulge = polyline.vertices[index].bulge; + let shape = cadkernel::geom2d::BulgeArc::from_bulge( + [source[index].x, source[index].y], + [source[next].x, source[next].y], + bulge, + )?; + Some(PolylineArc { + arc: GArc { + circle: GCircle { + center: GPoint::new( + sys.add_param(shape.center[0], false), + sys.add_param(shape.center[1], false), + ), + rad: sys.add_param(shape.radius, false), + }, + start: points[index], + end: points[next], + start_angle: sys.add_param(shape.start_angle, false), + end_angle: sys.add_param(shape.start_angle + shape.sweep, false), + }, + sweep: shape.sweep, + }) + }) + .collect(); + Some(EntityGeom::Polyline { + points: Rc::new(points), + straight: Rc::new( + polyline + .vertices + .iter() + .map(|vertex| vertex.bulge.abs() <= 1e-9) + .collect(), + ), + arcs: Rc::new(arcs), + closed: polyline.is_closed(), + }) + } + EntityType::Line(l) => { + let p1 = GPoint::new( + sys.add_param(l.start.x, false), + sys.add_param(l.start.y, false), + ); + 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), + sys.add_param(c.center.y, false), + ); + let rad = sys.add_param(c.radius, false); + Some(EntityGeom::Circle(GCircle { center, rad })) + } + EntityType::Arc(a) => { + // Full registration: center/radius, both angles, and real + // start/end points seeded from acadrust's own `start_point`/ + // `end_point` (already consistent with center/radius/angle at + // registration time) — `solve_scope` adds the arc-rules + // constraints that keep them that way under solving. See this + // module's doc comment. + let center = GPoint::new( + sys.add_param(a.center.x, false), + sys.add_param(a.center.y, false), + ); + let rad = sys.add_param(a.radius, false); + let start_angle = sys.add_param(a.start_angle, false); + let end_angle = sys.add_param( + a.start_angle + (a.end_angle - a.start_angle).rem_euclid(std::f64::consts::TAU), + false, + ); + let start_seed = a.start_point(); + let end_seed = a.end_point(); + let start = GPoint::new( + sys.add_param(start_seed.x, false), + sys.add_param(start_seed.y, false), + ); + let end = GPoint::new( + sys.add_param(end_seed.x, false), + sys.add_param(end_seed.y, false), + ); + Some(EntityGeom::Arc(GArc { + circle: GCircle { center, rad }, + start, + end, + start_angle, + end_angle, + })) + } + EntityType::Ellipse(el) => { + // acadrust's `Ellipse` is center + major-axis vector (its length + // is the major radius) + minor/major ratio; `cadkernel_constraints::geo:: + // Ellipse` is center + one focus + minor radius. Converting: + // minor_radius = major_radius * ratio, then the focus distance + // c follows from a² = b² + c² (standard ellipse identity), and + // focus1 sits `c` along the major-axis direction from center. + let major_radius = el.major_axis.length(); + let minor_radius = major_radius * el.minor_axis_ratio; + let focus_dist = (major_radius * major_radius - minor_radius * minor_radius) + .max(0.0) + .sqrt(); + // Direction is meaningless once major_radius is ~0 (a + // degenerate point-ellipse) — arbitrarily fall back to +X + // rather than dividing by ~0 in `normalize`. + let unit_major = if major_radius > 1e-9 { + el.major_axis.normalize() + } else { + acadrust::types::Vector3::UNIT_X + }; + let focus1_point = el.center + unit_major * focus_dist; + let center = GPoint::new( + sys.add_param(el.center.x, false), + sys.add_param(el.center.y, false), + ); + let focus1 = GPoint::new( + sys.add_param(focus1_point.x, false), + sys.add_param(focus1_point.y, false), + ); + let radmin = sys.add_param(minor_radius, false); + Some(EntityGeom::Ellipse(GEllipse { + center, + focus1, + 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 [`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. +fn resolve_ref( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + r: ParametricRef, +) -> Option { + if let Some(geometry) = cache.get(&r.entity) { + return Some(geometry.clone()); + } + let geometry = register_entity(document, sys, r.entity)?; + cache.insert(r.entity, geometry.clone()); + 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 let Some(segment) = reference + .segment_midpoint_index() + .and_then(|index| geometry.arc_segment(index)) + { + let (start, end) = { + let store = sys.store(); + ( + store.get(segment.arc.start_angle), + store.get(segment.arc.end_angle), + ) + }; + let parameter_value = (start + end) * 0.5; + let parameter = sys.add_param(parameter_value, false); + sys.add_constraint(Rc::new(CenterOfGravity::new( + parameter, + vec![segment.arc.start_angle, segment.arc.end_angle], + vec![0.5, 0.5], + ))); + let value = cadkernel_constraints::geo::Curve::value( + &segment.arc, + sys.store(), + parameter_value, + 0.0, + None, + ); + let point = GPoint::new(sys.add_param(value.x, false), sys.add_param(value.y, false)); + let curve = Rc::new(segment.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))); + 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 { + normal.x.abs() <= PLANE_EPS + && normal.y.abs() <= PLANE_EPS + && (normal.z - 1.0).abs() <= PLANE_EPS +} + +/// Returns the world-XY plane elevation supported by the current 2D bridge. +fn entity_plane_z(entity: &EntityType) -> Option { + let z = match entity { + EntityType::Point(point) if is_world_z(point.normal) => point.location.z, + EntityType::Insert(insert) if is_world_z(insert.normal) => insert.insert_point.z, + EntityType::Text(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::MText(text) if is_world_z(text.normal) => text.insertion_point.z, + EntityType::AttributeDefinition(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::AttributeEntity(attribute) if is_world_z(attribute.normal) => { + attribute.insertion_point.z + } + EntityType::Table(table) if is_world_z(table.normal) => table.insertion_point.z, + EntityType::Line(line) + if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => + { + 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) + if is_world_z(ellipse.normal) && ellipse.major_axis.z.abs() <= PLANE_EPS => + { + ellipse.center.z + } + EntityType::Spline(spline) => { + let curve = crate::entities::spline::nurbs3(spline)?; + let first = *curve.control_points().first()?; + if !curve + .control_points() + .iter() + .all(|point| (point[2] - first[2]).abs() <= PLANE_EPS) + { + return None; + } + first[2] + } + EntityType::LwPolyline(polyline) if is_world_z(polyline.normal) => { + let points = super::dimension_assoc::source_points(entity); + let first = *points.first()?; + if !points + .iter() + .all(|point| (point.z - first.z).abs() <= PLANE_EPS) + { + return None; + } + first.z + } + EntityType::Polyline2D(polyline) if is_world_z(polyline.normal) => { + let points = super::dimension_assoc::source_points(entity); + let first = *points.first()?; + if !points + .iter() + .all(|point| (point.z - first.z).abs() <= PLANE_EPS) + { + return None; + } + first.z + } + _ => return None, + }; + z.is_finite().then_some(z) +} + +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 { + return false; + }; + let Some(z) = entity_plane_z(entity) else { + return false; + }; + if plane_z.is_some_and(|expected: f64| (expected - z).abs() > PLANE_EPS) { + return false; + } + plane_z = Some(z); + } + plane_z.is_some() +} + +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 + ); + (!must_be_positive || value > 0.0).then_some(value) +} + +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, +/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be +/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape +/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, +/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint +/// that can't be built is simply skipped for this solve, not an error: +/// geometry it would have constrained is left alone, matching how a +/// dangling associative-dimension reference degrades today rather than +/// aborting the whole recompute. (A named-parameter *cycle* should be +/// unreachable here — `ParameterTable::set` already refuses to create one — +/// so it gets the same treatment as any other resolve failure rather than a +/// special case.) +fn build_constraint( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + params: &ParameterTable, + 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: 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), + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?), + _ => None, + } + }; + let whole_circle = + |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(_) + | EntityGeom::Spline { .. } => None, + }; + let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + resolve_constraint_point(document, sys, cache, r) + }; + + let point_on_bounded_arc = |sys: &mut System, point: GPoint, arc: GArc| { + let initial = BoundedArcValue::initial_parameter(sys.store(), point, arc); + let parameter = sys.add_param(initial, false); + vec![ + Rc::new(BoundedArcValue::new(point, point.x, arc, parameter)) as Rc, + Rc::new(BoundedArcValue::new(point, point.y, arc, parameter)) as Rc, + ] + }; + + // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two + // points (an endpoint, a circle's center via the existing `-3` marker, + // or a plain point) held equal on both axes. Only the DWG-native class + // name and the UI entry point that produces their `refs` differ. + let point_pair_equal = |sys: &mut System, + cache: &mut HashMap<_, _>, + 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 { + return Vec::new(); + }; + vec![ + Rc::new(Equal::new(pa.x, pb.x, 1.0)), + Rc::new(Equal::new(pa.y, pb.y, 1.0)), + ] + }; + + match c.kind { + ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { + point_pair_equal(sys, cache, &c.refs) + } + ConstraintKind::Horizontal => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.y, line.p2.y, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.y, b.y, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, + ConstraintKind::Vertical => match c.refs.as_slice() { + [r] => whole_line(sys, cache, *r) + .map(|line| { + vec![Rc::new(Equal::new(line.p1.x, line.p2.x, 1.0)) as Rc] + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.x, b.x, 1.0))], + _ => Vec::new(), + }, + _ => Vec::new(), + }, + ConstraintKind::Parallel => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] + } + ConstraintKind::Perpendicular => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + moving, + fixed, + ))] + } + ConstraintKind::Equal => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + 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))) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) { + return vec![Rc::new(EqualMajorAxesConic::new( + GConic::Ellipse(a), + GConic::Ellipse(b), + ))]; + } + let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] + } + ConstraintKind::EqualDistance => { + let [a, b, c2, d] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(pc), Some(pd)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + point_ref(sys, cache, *c2), + point_ref(sys, cache, *d), + ) else { + return Vec::new(); + }; + vec![Rc::new(EqualLineLength::new( + GLine { p1: pc, p2: pd }, + GLine { p1: pa, p2: pb }, + ))] + } + ConstraintKind::Colinear => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(onto), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + else { + return Vec::new(); + }; + // Pinning both of `moving`'s endpoints onto `onto`'s infinite + // line forces the two to coincide (as long as `moving`'s own + // two points stay distinct) — no dedicated "colinear" primitive + // needed, `PointOnLine` applied twice does it. + vec![ + Rc::new(PointOnLine::new(moving.p1, onto)), + Rc::new(PointOnLine::new(moving.p2, onto)), + ] + } + ConstraintKind::Midpoint => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(l)) = (point_ref(sys, cache, *a), bounded_line(sys, cache, *b)) + else { + return Vec::new(); + }; + vec![ + Rc::new(CenterOfGravity::new( + p.x, + vec![l.p1.x, l.p2.x], + vec![0.5, 0.5], + )), + Rc::new(CenterOfGravity::new( + p.y, + vec![l.p1.y, l.p2.y], + vec![0.5, 0.5], + )), + ] + } + ConstraintKind::PointOnCurve => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(p), Some(geom)) = ( + point_ref(sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + match geom { + 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))] + } + EntityGeom::Arc(a) => point_on_bounded_arc(sys, p, a), + 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) + .map(|line| { + vec![Rc::new(PointOnLine::new(p, line)) as Rc] + }) + .or_else(|| { + geom.arc_segment(index) + .map(|segment| point_on_bounded_arc(sys, p, segment.arc)) + }) + }) + .unwrap_or_default(), + EntityGeom::Point(_) => Vec::new(), + } + } + ConstraintKind::Symmetric => { + let [a, b, m] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(pa), Some(pb), Some(mirror)) = ( + point_ref(sys, cache, *a), + point_ref(sys, cache, *b), + whole_line(sys, cache, *m), + ) else { + return Vec::new(); + }; + let pair = GLine { p1: pa, p2: pb }; + vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ] + } + ConstraintKind::Fixed => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + if r.segment_index().is_some() { + let Some(line) = whole_line(sys, cache, *r) else { + return Vec::new(); + }; + 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), + ) + }; + return vec![ + Rc::new(Equal::new(line.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(line.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(line.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(line.p2.y, sys.add_param(y2, true), 1.0)), + ]; + } + if r.marker.is_some() { + let Some(point) = point_ref(sys, cache, *r) else { + return Vec::new(); + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + return vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ]; + } + let Some(geom) = resolve_ref(document, sys, cache, *r) else { + return Vec::new(); + }; + match geom { + EntityGeom::Point(point) => { + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + vec![ + Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), + Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), + ] + } + EntityGeom::Polyline { points, .. } => { + let values: Vec<_> = points + .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() + } + EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(l.p1.x), + store.get(l.p1.y), + store.get(l.p2.x), + store.get(l.p2.y), + ) + }; + vec![ + Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), + Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), + Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), + Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), + ] + } + EntityGeom::Circle(circ) => { + let (cx, cy, r) = { + let store = sys.store(); + ( + store.get(circ.center.x), + store.get(circ.center.y), + store.get(circ.rad), + ) + }; + vec![ + Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), + ] + } + // Pinning the 5 intrinsic params (center, radius, both + // angles) is enough — `start`/`end` are already tied to + // those via the arc-rules `CurveValue` constraints + // `solve_scope` adds for every arc, so pinning them too + // would just be redundant. + EntityGeom::Arc(a) => { + let (cx, cy, r, sa, ea) = { + let store = sys.store(); + ( + store.get(a.circle.center.x), + store.get(a.circle.center.y), + store.get(a.circle.rad), + store.get(a.start_angle), + store.get(a.end_angle), + ) + }; + vec![ + Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), + Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), + Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), + Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), + ] + } + // Pinning center + radmin is enough — `focus1` is already + // tied to `center` by the ellipse-rules `Difference` + // constraints `solve_scope` adds for every registered + // ellipse (see this module's doc comment), so with `center` + // pinned here too, pinning `focus1` again would just be + // redundant (same reasoning as Arc's `Fixed` arm above). + EntityGeom::Ellipse(el) => { + let (cx, cy, b) = { + let store = sys.store(); + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.radmin), + ) + }; + vec![ + Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), + Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), + 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 => { + let [a, b] = c.refs.as_slice() 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(); + }; + 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(); + }; + let (Some(fixed), Some(moving)) = + (whole_line(sys, cache, *a), whole_line(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(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(); + }; + let Some(circle) = whole_circle(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(Equal::new(circle.rad, target, 1.0))] + } + // Same math as `Radius`, just `radius = target / 2` instead of + // `radius = target` — `Equal`'s `ratio` param already supports + // this, matching how the native format stores Diameter as the same + // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte + // rather than a distinct one (`dwg_native_constraints.rs`). + ConstraintKind::Diameter => { + let Some(r) = c.refs.first() else { + return Vec::new(); + }; + let Some(circle) = whole_circle(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(Equal::new(circle.rad, target, 0.5))] + } + // 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(); + }; + 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(); + }; + 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 => { + let [a, b] = c.refs.as_slice() 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(); + }; + 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 => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + // An `Arc`'s `.circle` behaves identically to a plain `Circle` + // for tangency math — normalize both refs down first so the + // match below doesn't need every Circle/Arc combination + // written out separately. + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { + (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { + let store = sys.store(); + let (x1, y1, r1) = ( + store.get(c1.center.x), + store.get(c1.center.y), + store.get(c1.rad), + ); + let (x2, y2, r2) = ( + store.get(c2.center.x), + store.get(c2.center.y), + store.get(c2.rad), + ); + let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt(); + // One circle already sits inside the other's span, rather + // than the two side by side — pick internal tangency so + // the first solve doesn't have to cross the singularity + // between the two tangency configurations. + let internal = center_dist < (r1 - r2).abs(); + vec![Rc::new(TangentCircumf::new( + c1.center, c2.center, c1.rad, c2.rad, internal, + ))] + } + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + let store = sys.store(); + let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); + let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); + let (x2, y2) = (store.get(line.p2.x), store.get(line.p2.y)); + // Signed area of (p2-p1) × (center-p1) — same formula + // `C2LDistance::signed_value` itself uses — so `ccw`'s + // sign matches whichever side the circle already sits on. + let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1); + let ccw = area >= 0.0; + // A driven, fixed zero: with `internal: false` this makes + // `C2LDistance`'s target exactly the circle's own radius + // (see its `error_grad`), i.e. plain tangency rather than + // an offset distance. + let zero = sys.add_param(0.0, true); + vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] + } + (CircleOrLine::Ellipse(ellipse), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Ellipse(ellipse)) => { + vec![Rc::new(TangentEllipseLine::new(line, ellipse))] + } + _ => Vec::new(), + } + } + // 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 + // `PointOnCurve` already uses for a point-on-line case. + ConstraintKind::Normal => { + let [a, b] = c.refs.as_slice() else { + return Vec::new(); + }; + let (Some(ga), Some(gb)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), + ) else { + return Vec::new(); + }; + match (as_circle_or_line(ga, *a), as_circle_or_line(gb, *b)) { + (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) + | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + vec![Rc::new(PointOnLine::new(circ.center, line))] + } + // Line-Line has no meaning here (that's `Perpendicular`); + // Circle-Circle "normal" (orthogonal circles) needs a + // different, not-yet-implemented relation. + _ => Vec::new(), + } + } + } +} + +fn smooth_target_jet(entity: &EntityType, marker: i32) -> Option { + let parameter = match marker { + 0 => 0.0, + 1 => 1.0, + _ => return None, + }; + let curve = crate::entities::curve::entity_curve(entity)?; + if curve.is_closed() { + return None; + } + let step = if marker == 0 { 1.0e-4 } else { -1.0e-4 }; + let point = curve.point_at(parameter); + let tangent = curve.tangent_at(parameter); + let next = curve.point_at(parameter + step); + let third = curve.point_at(parameter + 2.0 * step); + Some(cadkernel::space::CurveJet { + point, + tangent, + curvature: cadkernel::space::curve::curvature_through(point, next, third), + }) +} + +fn apply_smooth_constraints( + document: &acadrust::CadDocument, + set: &ParametricConstraintSet, + results: &mut Vec<(Handle, EntityType)>, +) { + for constraint in set + .constraints + .iter() + .filter(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth) + { + let [source_ref, target_ref] = constraint.refs.as_slice() else { + continue; + }; + let source = results + .iter() + .find(|(handle, _)| *handle == source_ref.entity) + .map(|(_, entity)| entity.clone()) + .or_else(|| document.get_entity(source_ref.entity).cloned()); + let target = results + .iter() + .find(|(handle, _)| *handle == target_ref.entity) + .map(|(_, entity)| entity.clone()) + .or_else(|| document.get_entity(target_ref.entity).cloned()); + let (Some(EntityType::Spline(mut spline)), Some(target)) = (source, target) else { + continue; + }; + let endpoint = match source_ref.marker { + Some(0) => cadkernel::space::SplineEnd::Start, + Some(1) => cadkernel::space::SplineEnd::End, + _ => continue, + }; + let Some(target_marker) = target_ref.marker else { + continue; + }; + let Some(target_jet) = smooth_target_jet(&target, target_marker) else { + continue; + }; + let Some(curve) = crate::entities::spline::nurbs3(&spline) else { + continue; + }; + let Some(smoothed) = cadkernel::space::smooth_nurbs_endpoint(&curve, endpoint, target_jet) + else { + continue; + }; + crate::entities::spline::replace_with_nurbs(&mut spline, &smoothed); + let updated = EntityType::Spline(spline); + if let Some((_, entity)) = results + .iter_mut() + .find(|(handle, _)| *handle == source_ref.entity) + { + *entity = updated; + } else { + results.push((source_ref.entity, updated)); + } + } +} + +const MOVE_EPS: f64 = 1e-9; +type SolveResult = ( + Vec<(Handle, EntityType)>, + usize, + Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )>, +); + +fn retained_line_length(entity: &EntityType, segment: Option) -> Option { + match (entity, segment) { + (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::LwPolyline(polyline), Some(index)) => { + let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; + let a = world.vertices.get(index)?.location; + let b = if let Some(vertex) = world.vertices.get(index + 1) { + vertex.location + } else if world.is_closed { + world.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + (EntityType::Polyline2D(polyline), Some(index)) => { + let a = polyline.vertices.get(index)?.location; + let b = if let Some(vertex) = polyline.vertices.get(index + 1) { + vertex.location + } else if polyline.is_closed() { + polyline.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + _ => None, + } +} + +fn retained_radius(entity: &EntityType) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.radius), + EntityType::Arc(arc) => Some(arc.radius), + _ => None, + } +} + +/// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document +/// geometry, solves it, and returns the resulting entity states for every +/// handle whose registered coordinates actually moved beyond floating-point +/// noise, plus the scope's total remaining degrees of freedom and any +/// redundant/conflicting constraints, resolved +/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer +/// to remove. `None` if nothing in the scope could be built (no constraint +/// resolved to anything). DOF/conflicts are still returned even when every +/// subsystem fails to solve (rank is a structural property of the Jacobian, +/// not of whether Dogleg converged) — only the geometry write-back is gated +/// on a successful solve. +fn solve_scope( + document: &acadrust::CadDocument, + drawing_params: &ParameterTable, + set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + retain_size: bool, + retained_before: &HashMap>, +) -> 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 + // `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 `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. + let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); + + for c in &set.constraints { + for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + owner.push((constraint.clone(), c.id)); + sys.add_constraint(constraint); + } + } + + let has_smooth = set + .constraints + .iter() + .any(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth); + if cache.is_empty() && !has_smooth { + return None; + } + + // Preserve current line lengths and radii for this solve when + // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the + // solution without becoming persistent dimensional constraints. + if retain_size { + for (handle, geom) in &cache { + match geom { + EntityGeom::Line(line) => { + let current_length = { + let store = sys.store(); + let dx = store.get(line.p2.x) - store.get(line.p1.x); + let dy = store.get(line.p2.y) - store.get(line.p1.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, None)) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, target))); + } + EntityGeom::Polyline { + points, + closed, + .. + } => { + for index in 0..points.len() { + let Some(a) = points.get(index).copied() else { + continue; + }; + let b = if let Some(point) = points.get(index + 1).copied() { + Some(point) + } else if *closed { + points.first().copied() + } else { + None + }; + let Some(b) = b else { continue }; + let current_length = { + let store = sys.store(); + let dx = store.get(b.x) - store.get(a.x); + let dy = store.get(b.y) - store.get(a.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, Some(index))) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + } + } + EntityGeom::Circle(circle) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(circle.rad, target, 1.0))); + } + EntityGeom::Arc(arc) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(arc.circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); + } + _ => {} + } + } + } + + // 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 `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. + let add_arc_rules = |sys: &mut System, arc: GArc, sweep: Option| { + let curve = Rc::new(arc); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.x, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.start, + arc.start.y, + curve.clone(), + arc.start_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.x, + curve.clone(), + arc.end_angle, + ))); + sys.add_constraint(Rc::new(CurveValue::new( + arc.end, + arc.end.y, + curve, + arc.end_angle, + ))); + if let Some(sweep) = sweep { + let target = sys.add_param(sweep, true); + sys.add_constraint(Rc::new(Difference::new( + arc.start_angle, + arc.end_angle, + target, + ))); + } + }; + for geom in cache.values() { + match geom { + EntityGeom::Arc(arc) => add_arc_rules(&mut sys, *arc, None), + EntityGeom::Polyline { arcs, .. } => { + for segment in arcs.iter().flatten() { + add_arc_rules(&mut sys, segment.arc, Some(segment.sweep)); + } + } + _ => {} + } + } + + // Ellipse rules (same motivation as arc rules, different mechanism): + // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an + // offset from `center`. If some constraint (e.g. Concentric) pulls only + // on `center` and nothing references `focus1`/`radmin`, the solver + // correctly leaves `focus1`'s *absolute* coordinates untouched — but + // `center` moved and `focus1` didn't, so the derived vector + // `focus1 - center` (which is what `major_axis` direction/length and + // `minor_axis_ratio` are actually computed from on write-back) changes + // anyway, making the ellipse appear to rotate and reshape even though + // no shape parameter was itself pulled on. Pinning `focus1 - center` to + // its pre-solve (seed) value forces `focus1` to translate rigidly with + // `center`, keeping orientation/eccentricity invariant unless something + // actually constrains `focus1`/`radmin` directly (only `Fixed` does + // today). `radmin` needs no equivalent rule — as a lone scalar it isn't + // coupled to `center`'s movement, so it already stays put on its own. + for geom in cache.values() { + let EntityGeom::Ellipse(el) = geom else { + continue; + }; + let (cx, cy, fx, fy) = { + let store = sys.store(); + ( + store.get(el.center.x), + store.get(el.center.y), + store.get(el.focus1.x), + store.get(el.focus1.y), + ) + }; + let dx = sys.add_param(fx - cx, true); + let dy = sys.add_param(fy - cy, true); + sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); + sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + } + + // The first ordered Coincident selection is the anchor for this solve. + // Pin either its addressed point or every defining parameter of its whole + // curve temporarily; the relation remains persistent, while these pins do + // not enter the drawing's constraint graph. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|item| item.entity == reference.entity)) + { + continue; + } + let params_to_pin = if reference.marker.is_some() && reference.segment_index().is_none() { + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + .map(|point| vec![point.x, point.y]) + .unwrap_or_default() + } else { + match resolve_ref(document, &mut sys, &mut cache, *reference) { + Some(EntityGeom::Point(point)) => vec![point.x, point.y], + Some(EntityGeom::Line(line)) + | Some(EntityGeom::Ray(line)) + | Some(EntityGeom::XLine(line)) => { + vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y] + } + Some(EntityGeom::Polyline { points, .. }) => points + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + Some(EntityGeom::Circle(circle)) => { + vec![circle.center.x, circle.center.y, circle.rad] + } + Some(EntityGeom::Arc(arc)) => vec![ + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + arc.start_angle, + arc.end_angle, + ], + Some(EntityGeom::Ellipse(ellipse)) => vec![ + ellipse.center.x, + ellipse.center.y, + ellipse.focus1.x, + ellipse.focus1.y, + ellipse.radmin, + ], + Some(EntityGeom::Spline { curve, .. }) => curve + .poles + .iter() + .flat_map(|point| [point.x, point.y]) + .collect(), + None => Vec::new(), + } + }; + for parameter in params_to_pin { + let value = sys.store().get(parameter); + let target = sys.add_param(value, true); + sys.add_constraint(Rc::new(Equal::new(parameter, target, 1.0))); + } + } + + let partitions = sys.partition(); + for sub in &partitions { + if solve_dl(sub, sys.store_mut()) == SolveStatus::Failed { + solve_lm(sub, sys.store_mut()); + } + } + // `System::partition` only includes parameters referenced by a + // constraint, so an otherwise registered but unconstrained coordinate (e.g. a + // line's X after only a Horizontal constraint pins its Ys) is invisible + // to `diagnose` entirely, undercounting DOF. Every such untouched free + // param is unconstrained on its own, i.e. exactly 1 DOF each — added + // back here rather than fixed in `cadkernel_constraints::diagnosis`, which correctly + // has no opinion on params outside the `SubSystem` it was handed. + let total_free: usize = cache + .values() + .map(|g| match g { + EntityGeom::Point(_) => 2, + EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, + EntityGeom::Polyline { points, arcs, .. } => { + points.len() * 2 + arcs.iter().flatten().count() * 5 + } + EntityGeom::Circle(_) => 3, + // Raw param count (center×2, rad, start×2, end×2, both + // angles) — same "raw, not netted against its own + // constraints" convention as every other arm here. The four + // arc-rules `CurveValue` constraints (always present, added + // just above) already reduce this to 5 *effective* DOF through + // the normal `touched_free`/`diag.dof` accounting below, the + // 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(); + let mut dof = total_free.saturating_sub(touched_free); + let mut conflicts: Vec<( + ConstraintId, + cadkernel_constraints::diagnosis::RedundancyKind, + )> = Vec::new(); + for sub in &partitions { + let diag = cadkernel_constraints::diagnosis::diagnose(sub, sys.store()); + dof += diag.dof; + if diag.redundant.is_empty() { + continue; + } + 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(), &reportable) + { + let row_constraint = &sub.constraints()[row]; + if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { + conflicts.push((id, kind)); + } + } + } + + let store = sys.store(); + let mut results = Vec::new(); + for (&handle, geom) in &cache { + let Some(entity) = document.get_entity(handle) else { + continue; + }; + match (entity, geom) { + (EntityType::Point(point), EntityGeom::Point(geometry)) => { + let x = store.get(geometry.x); + let y = store.get(geometry.y); + if (x - point.location.x).abs() > MOVE_EPS + || (y - point.location.y).abs() > MOVE_EPS + { + let mut updated = point.clone(); + updated.location.x = x; + updated.location.y = y; + results.push((handle, EntityType::Point(updated))); + } + } + (EntityType::Insert(insert), EntityGeom::Point(geometry)) => { + let mut updated = insert.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + insert.insert_point.z, + ); + if (next - insert.insert_point).length() > MOVE_EPS { + updated.insert_point = next; + results.push((handle, EntityType::Insert(updated))); + } + } + (EntityType::Text(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + let delta = next - text.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + if let Some(alignment) = &mut updated.alignment_point { + *alignment = *alignment + delta; + } + results.push((handle, EntityType::Text(updated))); + } + } + (EntityType::MText(text), EntityGeom::Point(geometry)) => { + let mut updated = text.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + text.insertion_point.z, + ); + if (next - text.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::MText(updated))); + } + } + (EntityType::AttributeDefinition(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeDefinition(updated))); + } + } + (EntityType::AttributeEntity(attribute), EntityGeom::Point(geometry)) => { + let mut updated = attribute.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + attribute.insertion_point.z, + ); + let delta = next - attribute.insertion_point; + if delta.length() > MOVE_EPS { + updated.insertion_point = next; + updated.alignment_point = updated.alignment_point + delta; + results.push((handle, EntityType::AttributeEntity(updated))); + } + } + (EntityType::Table(table), EntityGeom::Point(geometry)) => { + let mut updated = table.clone(); + let next = Vector3::new( + store.get(geometry.x), + store.get(geometry.y), + table.insertion_point.z, + ); + if (next - table.insertion_point).length() > MOVE_EPS { + updated.insertion_point = next; + results.push((handle, EntityType::Table(updated))); + } + } + (EntityType::Line(l), EntityGeom::Line(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), + ); + if (x1 - l.start.x).abs() > MOVE_EPS + || (y1 - l.start.y).abs() > MOVE_EPS + || (x2 - l.end.x).abs() > MOVE_EPS + || (y2 - l.end.y).abs() > MOVE_EPS + { + let mut updated = l.clone(); + updated.start.x = x1; + updated.start.y = y1; + updated.end.x = x2; + updated.end.y = y2; + 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; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::LwPolyline(updated))); + } + } + (EntityType::Polyline2D(polyline), EntityGeom::Polyline { points, .. }) => { + let mut updated = polyline.clone(); + let mut changed = false; + for (vertex, point) in updated.vertices.iter_mut().zip(points.iter()) { + let x = store.get(point.x); + let y = store.get(point.y); + if (x - vertex.location.x).abs() > MOVE_EPS + || (y - vertex.location.y).abs() > MOVE_EPS + { + vertex.location.x = x; + vertex.location.y = y; + changed = true; + } + } + if changed { + results.push((handle, EntityType::Polyline2D(updated))); + } + } + (EntityType::Circle(c), EntityGeom::Circle(g)) => { + let (cx, cy, r) = ( + store.get(g.center.x), + store.get(g.center.y), + store.get(g.rad), + ); + if (cx - c.center.x).abs() > MOVE_EPS + || (cy - c.center.y).abs() > MOVE_EPS + || (r - c.radius).abs() > MOVE_EPS + { + let mut updated = c.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + results.push((handle, EntityType::Circle(updated))); + } + } + (EntityType::Arc(a), EntityGeom::Arc(g)) => { + // Center/radius/angles only — `start`/`end` are derived + // (acadrust's `Arc` has no separate stored fields for them; + // `start_point()`/`end_point()` compute them from these + // same four), so nothing further needs writing back for + // them specifically. + let (cx, cy, r) = ( + store.get(g.circle.center.x), + store.get(g.circle.center.y), + store.get(g.circle.rad), + ); + let (new_start_angle, new_end_angle) = + (store.get(g.start_angle), store.get(g.end_angle)); + if (cx - a.center.x).abs() > MOVE_EPS + || (cy - a.center.y).abs() > MOVE_EPS + || (r - a.radius).abs() > MOVE_EPS + || (new_start_angle - a.start_angle).abs() > MOVE_EPS + || (new_end_angle - a.end_angle).abs() > MOVE_EPS + { + let mut updated = a.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.radius = r; + updated.start_angle = new_start_angle; + updated.end_angle = new_end_angle; + results.push((handle, EntityType::Arc(updated))); + } + } + (EntityType::Ellipse(el), EntityGeom::Ellipse(g)) => { + // Converting back to acadrust's center + major-axis-vector + + // minor/major-ratio parametrization — the inverse of + // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already + // provided by `cadkernel_constraints::geo::Ellipse`) does the a²=b²+c² + // algebra; only the major-axis *direction* needs deriving + // here, from the (possibly moved) focus relative to center. + let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); + let (fx, fy) = (store.get(g.focus1.x), store.get(g.focus1.y)); + let minor_radius = store.get(g.radmin); + let (major_radius, _) = g.rad_maj_at(store, None); + let focus_dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); + // Direction is ill-defined once the ellipse is (near) + // circular — keep whatever direction it already had rather + // than snapping to an arbitrary axis. + let unit_major = if focus_dist > 1e-9 { + acadrust::types::Vector3::new(fx - cx, fy - cy, 0.0).normalize() + } else { + el.major_axis.normalize() + }; + let new_major_axis = unit_major * major_radius; + let new_ratio = if major_radius > 1e-9 { + minor_radius / major_radius + } else { + 0.0 + }; + if (cx - el.center.x).abs() > MOVE_EPS + || (cy - el.center.y).abs() > MOVE_EPS + || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS + || (new_major_axis.y - el.major_axis.y).abs() > MOVE_EPS + || (new_ratio - el.minor_axis_ratio).abs() > MOVE_EPS + { + let mut updated = el.clone(); + updated.center.x = cx; + updated.center.y = cy; + updated.major_axis = new_major_axis; + updated.minor_axis_ratio = new_ratio; + 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))); + } + } + _ => {} + } + } + apply_smooth_constraints(document, set, &mut results); + Some((results, dof, conflicts)) +} + +impl Scene { + /// Rejects constraints the 2D kernel bridge cannot represent before they + /// are persisted or reported as applied. + pub(crate) fn validate_parametric_constraint( + &self, + kind: ConstraintKind, + refs: &[ParametricRef], + driving_param: Option<&super::named_parameters::DrivingValue>, + ) -> Result<(), &'static str> { + if !refs_share_supported_plane(&self.document, refs) { + return Err( + "Constraint references must be supported entities on the same world-XY plane.", + ); + } + if kind == ConstraintKind::Smooth { + let [source_ref, target_ref] = refs else { + return Err("Smooth requires one spline endpoint and one target endpoint."); + }; + if source_ref.entity == target_ref.entity { + return Err("Smooth requires two different entities."); + } + let Some(EntityType::Spline(spline)) = self.document.get_entity(source_ref.entity) + else { + return Err("Smooth requires an open spline as its first reference."); + }; + if spline.flags.closed || spline.flags.periodic { + return Err("Smooth requires an open spline."); + } + let endpoint = match source_ref.marker { + Some(0) => cadkernel::space::SplineEnd::Start, + Some(1) => cadkernel::space::SplineEnd::End, + _ => return Err("Smooth requires a spline endpoint."), + }; + let Some(target_marker) = target_ref.marker else { + return Err("Smooth requires a target endpoint."); + }; + let Some(target) = self.document.get_entity(target_ref.entity) else { + return Err("Smooth target does not exist."); + }; + let Some(target_jet) = smooth_target_jet(target, target_marker) else { + return Err("The selected target does not support endpoint smoothing."); + }; + let Some(curve) = crate::entities::spline::nurbs3(spline) else { + return Err("The selected spline cannot be evaluated."); + }; + if cadkernel::space::smooth_nurbs_endpoint(&curve, endpoint, target_jet).is_none() { + return Err("The selected spline cannot satisfy curvature continuity."); + } + return Ok(()); + } + let validation_target = match driving_param { + Some(super::named_parameters::DrivingValue::Named(name)) + if !self.named_parameters.contains(name) => + { + Some(super::named_parameters::DrivingValue::Literal(1.0)) + } + value => value.cloned(), + }; + 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(); + if build_constraint( + &self.document, + &mut system, + &mut cache, + &self.named_parameters, + &candidate, + ) + .is_empty() + { + return Err("The selected entities or target value do not support this constraint."); + } + Ok(()) + } + + /// Re-solves every constraint scope touched by `changes`. Returns + /// the resulting `(Handle, ChangeKind::Modified)` entries the same way + /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` + /// to fold into its own `changes` vec. + #[allow(dead_code)] + pub(crate) fn refresh_parametric_constraints( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, &[], false) + } + + #[allow(dead_code)] + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, driven_refs, false) + } + + pub(crate) fn refresh_parametric_constraints_with_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_originals( + changes, + driven_refs, + retain_size, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) -> Vec<(Handle, ChangeKind)> { + // An erased entity takes its constraints with it instead of leaving + // dangling references. Done first, + // and unconditionally over every scope (not just ones a `touched` + // check would catch), so a scope left with zero constraints after + // this doesn't attempt a pointless resolve below. + // + // Recorded into the *same* undo transaction as the entity removal + // that caused it (audit finding: ERASE undo silently lost constraint + // 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. + for (handle, kind) in changes { + if *kind != ChangeKind::Removed { + continue; + } + 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.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 retained_before: HashMap> = if retain_size { + let mut retained: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + retained.insert(*handle, std::sync::Arc::new(entity.clone())); + } + retained + } else { + HashMap::new() + }; + let mut result = Vec::new(); + for i in 0..self.parametric_constraints.len() { + let touched = changes.iter().any(|(handle, _)| { + self.parametric_constraints[i] + .constraints_touching(*handle) + .next() + .is_some() + }); + if !touched { + continue; + } + let Some((solved, dof, conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + &self.parametric_constraints[i], + driven_refs, + retain_size, + &retained_before, + ) else { + continue; + }; + 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)); + } + if let Some(slot) = self.document.get_entity_mut(handle) { + *slot = new_entity; + } + result.push((handle, ChangeKind::Modified)); + } + } + result + } + + /// 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_parametric_constraints_preview( + &self, + touched: &[Handle], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) -> Vec<(Handle, EntityType)> { + if self.parametric_constraints.is_empty() || touched.is_empty() { + return Vec::new(); + } + let retained_before: HashMap> = retained_originals + .iter() + .map(|(handle, entity)| (*handle, std::sync::Arc::new(entity.clone()))) + .collect(); + let mut result = Vec::new(); + for set in &self.parametric_constraints { + let is_touched = touched + .iter() + .any(|handle| set.constraints_touching(*handle).next().is_some()); + if !is_touched { + continue; + } + let Some((solved, _dof, _conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + set, + driven_refs, + retain_size, + &retained_before, + ) + else { + continue; + }; + result.extend(solved); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::super::named_parameters::DrivingValue; + use super::super::parametric_constraints::{ + ConstraintKind, ParametricConstraintSet, ParametricRef, ParametricScope, + }; + use super::Scene; + use acadrust::entities::EntityType; + use acadrust::types::Vector3; + + #[test] + fn validation_rejects_unsupported_entity_shapes_and_planes() { + let mut scene = Scene::new(); + let circle = scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), + )); + assert!(scene + .validate_parametric_constraint( + ConstraintKind::Horizontal, + &[ParametricRef::whole(circle)], + None + ) + .is_err()); + + let lower = 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 upper = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 1.0), + ))); + assert!(scene + .validate_parametric_constraint( + ConstraintKind::Parallel, + &[ParametricRef::whole(lower), ParametricRef::whole(upper)], + None, + ) + .is_err()); + } + + #[test] + fn validation_enforces_dimensional_value_domains() { + let mut scene = Scene::new(); + let line = 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 refs = [ParametricRef::point(line, 0), ParametricRef::point(line, 1)]; + assert!(scene + .validate_parametric_constraint( + ConstraintKind::Distance, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_err()); + assert!(scene + .validate_parametric_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(-1.0)), + ) + .is_ok()); + assert!(scene + .validate_parametric_constraint( + ConstraintKind::DistanceX, + &refs, + Some(&DrivingValue::Literal(f64::NAN)), + ) + .is_err()); + } + + #[test] + fn bulged_polyline_segment_registers_as_a_kernel_arc() { + let mut scene = Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + let handle = scene.add_entity(EntityType::LwPolyline(polyline)); + let mut system = cadkernel_constraints::system::System::new(); + let geometry = super::register_entity(&scene.document, &mut system, handle).unwrap(); + let segment = geometry.arc_segment(0).expect("expected an arc segment"); + + assert!((system.store().get(segment.arc.circle.rad) - 5.0).abs() < 1.0e-9); + assert!((segment.sweep - std::f64::consts::PI).abs() < 1.0e-9); + } + + #[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/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] + fn one_edit_that_ripples_through_a_constraint_still_records_as_one_undo_step() { + 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::Parallel, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + scene.parametric_constraints.push(set); + + scene.begin_undo_recording(); + // A real command (Move, grip-drag commit, ...) records its own + // before-image before mutating. + let before_a = scene.document.get_entity_arc(a); + scene.record_undo_before(a, before_a); + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { + l.end = Vector3::new(10.0, 6.0, 0.0); + } + scene.bump_entities(&[(a, super::ChangeKind::Modified)]); + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); + + let (entities, _objects, _parametric_constraints, _named_parameters) = + recording.into_recorded_images(); + let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); + assert!( + touched.contains(&a), + "the directly-edited line must be in the undo delta" + ); + assert!( + touched.contains(&b), + "the constraint-solved neighbor must ride the same undo delta" + ); + } + + /// Erasing an entity records the removed constraints in the same undo + /// transaction as the geometry. + #[test] + fn erasing_a_constrained_entity_records_its_scope_for_undo() { + 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(10.0, 0.0, 0.0), + Vector3::new(20.0, 5.0, 0.0), + ))); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![ParametricRef::point(a, 1), ParametricRef::point(b, 0)], + None, + ); + assert_eq!( + scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1 + ); + + scene.begin_undo_recording(); + scene.erase_entities(&[a]); + + // Ensure the test exercises restoration rather than a no-op. + assert_eq!( + scene + .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" + ); + + let recording = scene + .take_undo_recording() + .expect("an undo recording should still be open"); + let (_entities, _objects, parametric_constraints, _named_parameters) = + recording.into_recorded_images(); + assert_eq!( + parametric_constraints.len(), + 1, + "the touched scope's before-image must be captured" + ); + let (scope, before) = ¶metric_constraints[0]; + assert_eq!(*scope, ParametricScope::ModelSpace); + assert_eq!( + before.constraints.len(), + 1, + "the before-image must still hold the constraint as it was before the erase" + ); + assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); + assert_eq!( + before.constraints[0].refs, + 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.parametric_constraint_set_mut(*scope) = before.clone(); + assert_eq!( + scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .len(), + 1, + "restoring the before-image must bring the constraint back" + ); + } + + /// `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] + fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() { + 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::Parallel, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + 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. + if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { + l.end = Vector3::new(10.0, 6.0, 0.0); + } + let b_before = scene.document.get_entity(b).cloned(); + + let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); + + assert_eq!( + scene.document.get_entity(b), + b_before.as_ref(), + "preview must not mutate the document" + ); + // The whole scope solves together (every registered entity's params + // are free, not just `b`'s — see `register_entity`'s doc comment), + // so `a` itself may also have shifted slightly to reach the nearest + // mutually-parallel configuration; read whichever position `a` ends + // up at from `solved` too, rather than assuming it stayed exactly + // where the simulated drag put it. + let line_dir = |entity: &EntityType| { + let EntityType::Line(l) = entity else { + panic!("expected a Line") + }; + (l.end.x - l.start.x, l.end.y - l.start.y) + }; + let dir_a = solved + .iter() + .find(|(h, _)| *h == a) + .map(|(_, e)| line_dir(e)) + .unwrap_or((10.0, 6.0)); + let (_, moved_entity) = solved + .iter() + .find(|(h, _)| *h == b) + .expect("b should be reported as moved"); + let dir_b = line_dir(moved_entity); + let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + assert!( + cross.abs() < 1e-6, + "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" + ); + } + + #[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(); + let target = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(5.0, 0.0, 0.0), + ))); + let mut spline = acadrust::entities::Spline::new(); + spline.degree = 3; + spline.control_points = vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 2.0, 0.0), + Vector3::new(3.0, 2.0, 0.0), + Vector3::new(5.0, 3.0, 0.0), + ]; + spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); + let spline = scene.add_entity(EntityType::Spline(spline)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Smooth, + vec![ + ParametricRef::point(spline, 0), + ParametricRef::point(target, 0), + ], + None, + ); + + if let Some(EntityType::Line(line)) = scene.document.get_entity_mut(target) { + line.start.y = 1.0; + line.end.y = 1.0; + } + scene.bump_entities(&[(target, super::ChangeKind::Modified)]); + + let EntityType::Spline(spline) = scene.document.get_entity(spline).unwrap() else { + panic!("expected spline"); + }; + let curve = crate::entities::spline::nurbs3(spline).unwrap(); + let jet = + cadkernel::space::CurveJet::from_nurbs(&curve, cadkernel::space::SplineEnd::Start) + .unwrap(); + assert!((jet.point[0] - 0.0).abs() < 1e-9); + assert!((jet.point[1] - 1.0).abs() < 1e-9); + assert!(jet.tangent[1].abs() < 1e-9, "jet={jet:?}"); + assert!(cadkernel::space::Vec3::from(jet.curvature).length() < 5e-3); + } +} diff --git a/src/scene/project.rs b/src/scene/project.rs index 29ef0735d..f20c10e38 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -386,12 +386,12 @@ impl Scene { /// needs the equivalent paper-space geometry explicitly. pub fn viewport_plot_fills( &self, - ) -> (Vec<(WireModel, f32)>, Vec, Vec) { + ) -> (Vec<(WireModel, f32)>, Vec, Vec, Vec) { use acadrust::entities::Viewport; use model::hatch_model::HatchPattern; if self.current_layout == "Model" { - return (Vec::new(), Vec::new(), Vec::new()); + return (Vec::new(), Vec::new(), Vec::new(), Vec::new()); } let paper_block = self.current_layout_block_handle(); let model_block = self.model_space_block_handle(); @@ -411,6 +411,7 @@ impl Scene { let mut pattern_wires = Vec::new(); let mut projected_hatches = Vec::new(); let mut projected_wipeouts = Vec::new(); + let mut projected_images = Vec::new(); for viewport in viewports { let Some(camera) = self.camera_for_viewport(viewport.common.handle) else { @@ -463,6 +464,58 @@ impl Scene { let frozen: rustc_hash::FxHashSet = viewport.frozen_layers.iter().copied().collect(); + let mut viewport_clips = vec![vec![ + [xmin as f64, ymin as f64], [xmax as f64, ymin as f64], + [xmax as f64, ymax as f64], [xmin as f64, ymax as f64], + ]]; + if !self.images.is_empty() && !viewport.clip_boundary_handle.is_null() { + let boundary = self.clip_boundary_polygon( + viewport.clip_boundary_handle, viewport.center.z as f32); + if boundary.len() >= 3 { + viewport_clips.push(boundary.iter().map(|p| [p[0] as f64, p[1] as f64]).collect()); + } + } + for mut plot in self.placed_images(model_block, Some(&frozen), + self.viewport_scale_handle(viewport.common.handle), + self.annotation_all_visible(), Some(viewport.common.handle), true) + { + let mut valid = true; + let mut project_point = |high: &mut [f32; 3], low: &mut [f32; 3]| { + let point = std::array::from_fn(|i| high[i] as f64 + low[i] as f64); + if let Some([x, y]) = project_3d(point) { + *high = [x, y, 0.0]; + *low = [0.0; 3]; + } else { + valid = false; + } + }; + for (high, low) in plot.image.corners.iter_mut().zip(&mut plot.image.corners_low) { + project_point(high, low); + } + for vertex in &mut plot.image.verts { + project_point(&mut vertex.pos, &mut vertex.pos_low); + } + for clip in &mut plot.clips { + for point in clip { + if let Some([x, y]) = project(point[0], point[1]) { + *point = [x as f64, y as f64]; + } else { + valid = false; + } + } + } + if !valid { + continue; + } + if [(0, xmin, xmax), (1, ymin, ymax)].iter().any(|&(axis, min, max)| { + plot.image.verts.iter().all(|v| v.pos[axis] < min) + || plot.image.verts.iter().all(|v| v.pos[axis] > max) + }) { + continue; + } + plot.clips.extend(viewport_clips.iter().cloned()); + projected_images.push(plot); + } let hatches = self.plot_hatches_for_block( model_block, Some(&frozen), @@ -536,7 +589,7 @@ impl Scene { } } - (pattern_wires, projected_hatches, projected_wipeouts) + (pattern_wires, projected_hatches, projected_wipeouts, projected_images) } /// A content viewport's clip boundary, projected into that viewport's diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..4ebab412c 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -72,6 +72,7 @@ impl Scene { if handles.is_empty() { return; } + self.selected_constraint = None; let expanded = self.expanded_with_leaders(handles); let mut changed = false; for handle in expanded { @@ -111,6 +112,7 @@ impl Scene { expanded } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { + self.selected_constraint = None; let handles = self.handles_expanded_for_leader_annotations(&[handle]); let mut changed = false; @@ -133,6 +135,7 @@ impl Scene { } pub fn deselect_all(&mut self) { + self.selected_constraint = None; if self.selected.is_empty() { return; } @@ -215,6 +218,7 @@ impl Scene { /// only when its contents actually changed. History/file/command paths must /// use this instead of assigning `selected` directly. pub(crate) fn replace_selection(&mut self, selected: HashSet) { + self.selected_constraint = None; let handles: Vec = selected.iter().copied().collect(); let selected: HashSet = self .handles_expanded_for_leader_annotations(&handles) @@ -750,7 +754,8 @@ impl Scene { | PropValue::FieldLwVaries { .. } | PropValue::EntityLink { .. } | PropValue::ParamRow { .. } - | PropValue::ParamAddRow => continue, + | PropValue::ParamAddRow + | PropValue::ParamsVisibilityToggle(_) => continue, } }; out.push(choice(prop.field, prop.label, editor)); @@ -887,7 +892,8 @@ impl Scene { | PropValue::FieldLwVaries { .. } | PropValue::EntityLink { .. } | PropValue::ParamRow { .. } - | PropValue::ParamAddRow => return None, + | PropValue::ParamAddRow + | PropValue::ParamsVisibilityToggle(_) => return None, }) } } @@ -1001,6 +1007,7 @@ impl Scene { self.hover_highlight = None; hover_changed = true; } + hover_changed |= self.constraint_hover_highlights.remove(&h); self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/sketch_constraints.rs b/src/scene/sketch_constraints.rs deleted file mode 100644 index cdbb50c9f..000000000 --- a/src/scene/sketch_constraints.rs +++ /dev/null @@ -1,1071 +0,0 @@ -//! Persistent sketch-constraint data types 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. -/// -/// Reuses the GsMarker convention `AssocDimensionReference::main_gs_marker` -/// already carries for associative-dimension endpoints -/// (`src/scene/dimension_assoc.rs`), rather than inventing a second -/// sub-element addressing scheme: `marker` indexes into -/// [`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 { - pub entity: Handle, - /// `None` addresses the entity as a whole — what a Radius, Length, or - /// whole-curve constraint (Parallel, Perpendicular, Equal, Horizontal, - /// Vertical) needs; a point-level constraint (Coincident, Distance - /// between two points, Angle at a shared vertex) sets `Some(marker)`. - pub marker: Option, -} - -impl SketchRef { - pub fn whole(entity: Handle) -> Self { - Self { - entity, - marker: None, - } - } - - pub fn point(entity: Handle, marker: i32) -> Self { - Self { - entity, - marker: Some(marker), - } - } - - /// The circle/arc-center special case (`marker == -3`), broken out as - /// its own constructor since `-3` alone reads as a magic number - /// everywhere it would otherwise appear. - pub fn center(entity: Handle) -> Self { - Self { - entity, - marker: Some(-3), - } - } -} - -/// The friendly, user-facing constraint types — the "what button did they -/// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps -/// onto (that mapping is `constraint_map`, a later stage; see the design -/// doc §2). Named and grouped the same way the existing one-shot ribbon -/// 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)] -pub enum ConstraintKind { - Coincident, - Horizontal, - Vertical, - Parallel, - Perpendicular, - Equal, - /// Distance between two points, or a single line's length, or a - /// circle/arc's diameter — which reading applies depends on `refs`' - /// shape, mirroring how `DistanceConstraintCommand` - /// (`src/modules/draw/constrain/value.rs`) already infers it from the - /// selected entity. - Distance, - Angle, - Radius, - Tangent, - /// An open spline endpoint remains curvature-continuous with another curve endpoint. - Smooth, - /// Two circles/arcs share a center — `refs`: `[center(a), center(b)]`. - /// Solves identically to `Coincident` (`sketch_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. - Concentric, - /// A point sits at a circle/arc's center — `refs`: `[point, center(circle)]`. - /// Same solver math as `Coincident`/`Concentric`, different DWG class - /// name (`ACCENTERPOINTCONSTRAINT`). - CenterPoint, - /// Two lines share the same infinite line — `refs`: `[whole(a), whole(b)]`. - Colinear, - /// A point sits at another line's midpoint — `refs`: `[point, whole(line)]`. - 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). - Fixed, - /// A point lies anywhere along a line's or circle's curve (not - /// restricted to an endpoint/center) — `refs`: `[point, whole(entity)]`. - PointOnCurve, - /// The distance between one point pair equals the distance between - /// another — `refs`: `[p1, p2, p3, p4]` (`dist(p1,p2) == dist(p3,p4)`). - /// For the supported entity types, equal curvature is already covered - /// by the circle/circle radius branch of `Equal`. - EqualDistance, - /// Two circles/arcs are mirror images of each other across a line — - /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the - /// circle-center-pair case (unambiguous with whole-entity selection); - /// point-symmetry about a point, and symmetry between two lines, aren't - /// modeled. - Symmetric, - /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: - /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` - /// (`ACRADIUSDIAMETERCONSTRAINT`), distinguished only by the - /// `RadiusDiameterConstrType` mode byte - /// (`dwg_native_constraints.rs`), rather than a distinct object type. - Diameter, - /// The X-only (resp. Y-only) component of the distance between two - /// points — `refs`: `[p1, p2]`, same shape as `Distance`. Same DWG - /// class as `Distance` (`ACDISTANCECONSTRAINT`) with its - /// `DirectionType` set to a fixed `(1,0,0)`/`(0,1,0)` direction. - DistanceX, - DistanceY, - /// 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 - /// passes through the circle's center" (a circle's radius is always - /// normal to its own tangent), so it solves via the same `PointOnLine` - /// primitive `PointOnCurve` already uses. Distinct from - /// `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, -} - -pub type ConstraintId = u32; - -/// One persisted constraint record: 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 { - pub id: ConstraintId, - pub kind: ConstraintKind, - 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 - /// 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, -} - -/// A constraint scope: model space or one block definition. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum SketchScope { - ModelSpace, - /// A block definition's `BlockRecord` handle — matches - /// `BlockEditSession::br_handle` - /// (`src/modules/draw/modify/block_edit.rs`). - Block(Handle), -} - -impl SketchScope { - /// 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, - } - } -} - -/// Every persisted constraint for one [`SketchScope`]. 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, - next_id: ConstraintId, - /// 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` - /// 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 { - Self { - scope, - constraints: Vec::new(), - next_id: 0, - dof: None, - conflicts: Vec::new(), - } - } - - /// Appends a constraint, assigning it a fresh id unique within this set. - pub fn add( - &mut self, - kind: ConstraintKind, - refs: Vec, - driving_param: Option, - ) -> ConstraintId { - let id = self.next_id; - self.next_id += 1; - self.constraints.push(SketchConstraint { - id, - kind, - refs, - driving_param, - enabled: true, - }); - id - } - - /// Removes a constraint by id. Returns whether one was actually removed. - pub fn remove(&mut self, id: ConstraintId) -> bool { - let before = self.constraints.len(); - self.constraints.retain(|c| c.id != id); - self.constraints.len() != before - } - - pub fn get(&self, id: ConstraintId) -> Option<&SketchConstraint> { - self.constraints.iter().find(|c| c.id == id) - } - - /// Every enabled constraint referencing `entity`. - 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)) - } - - /// Drops every constraint that references `entity` and returns the - /// removed constraint ids. - pub fn remove_all_touching(&mut self, entity: Handle) -> Vec { - let (removed, kept): (Vec<_>, Vec<_>) = self - .constraints - .drain(..) - .partition(|c| c.refs.iter().any(|r| r.entity == entity)); - self.constraints = kept; - removed.into_iter().map(|c| c.id).collect() - } -} - -/// Resolves a [`SketchRef`] 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). -/// -/// Solver-side registration reads raw entity fields directly. This helper is -/// for UI-side consumers that need the current world-space position. -pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { - if marker == -3 { - return match entity { - acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), - acadrust::EntityType::Arc(arc) => Some(arc.center_wcs()), - _ => None, - }; - } - if marker < 0 { - return None; - } - super::dimension_assoc::source_points(entity) - .get(marker as usize) - .copied() -} - -/// Below this squared distance (1e-6 world units), two points count as -/// already coincident for [`nearest_sketch_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( - document: &acadrust::CadDocument, - scope: SketchScope, - world_point: Vector3, - exclude: Option, -) -> Option { - let owner = scope.owner_handle(document); - let mut best: Option<(f64, SketchRef)> = 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))); - } - }; - for candidate in document.entities() { - let common = candidate.common(); - if common.owner_handle != owner || Some(common.handle) == exclude { - continue; - } - for (marker, point) in super::dimension_assoc::source_points(candidate) - .into_iter() - .enumerate() - { - consider(common.handle, marker as i32, point); - } - match candidate { - acadrust::EntityType::Circle(c) => consider(common.handle, -3, c.center_wcs()), - acadrust::EntityType::Arc(a) => consider(common.handle, -3, a.center_wcs()), - _ => {} - } - } - best.map(|(_, r)| r) -} - -impl ConstraintKind { - /// The short symbol a constraint glyph shows — matches the existing - /// ribbon icons (`crate::modules::parametric::{tools,value}`) for - /// the kinds that have a one-click button, so the same glyph means the - /// same thing in both places. - pub fn glyph_symbol(&self) -> &'static str { - match self { - ConstraintKind::Coincident => "≡", - ConstraintKind::Horizontal => "—", - ConstraintKind::Vertical => "│", - ConstraintKind::Parallel => "∥", - ConstraintKind::Perpendicular => "⊥", - ConstraintKind::Equal => "=", - ConstraintKind::Distance => "↔", - ConstraintKind::Angle => "∠", - ConstraintKind::Radius => "R", - ConstraintKind::Tangent => "T", - ConstraintKind::Smooth => "G²", - ConstraintKind::Concentric => "◎", - ConstraintKind::CenterPoint => "⊕", - ConstraintKind::Colinear => "L", - ConstraintKind::Midpoint => "M", - ConstraintKind::Fixed => "F", - ConstraintKind::PointOnCurve => "∈", - ConstraintKind::EqualDistance => "≐", - ConstraintKind::Symmetric => "S", - ConstraintKind::Diameter => "⌀", - ConstraintKind::DistanceX => "↔ₓ", - ConstraintKind::DistanceY => "↔ᵧ", - ConstraintKind::Normal => "⊾", - ConstraintKind::ArcLength => "⌢", - } - } -} - -/// 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 { - match (constraint.kind, &constraint.driving_param) { - (ConstraintKind::Angle, Some(DrivingValue::Literal(value))) => { - format!("{} {value:.1}°", constraint.kind.glyph_symbol()) - } - (_, Some(DrivingValue::Literal(value))) => { - format!("{} {value:.2}", constraint.kind.glyph_symbol()) - } - // A named reference has no single resolved number to show without - // threading `ParameterTable` into every glyph-render call site - // (`src/ui/overlay.rs`) — showing the name itself is enough for now; - // stage 4's parameters panel is the natural place to reconsider this - // once a named `driving_param` can actually be authored through the - // UI (nothing can yet — this arm exists so the match is exhaustive - // and correct ahead of that UI, not because it's reachable today). - (_, Some(DrivingValue::Named(name))) => { - format!("{} {name}", constraint.kind.glyph_symbol()) - } - (_, None) => constraint.kind.glyph_symbol().to_string(), - } -} - -/// World-space anchor and outward direction for a constraint glyph. -pub(crate) fn glyph_placement( - document: &acadrust::CadDocument, - constraint: &SketchConstraint, -) -> Option<(Vector3, Vector3)> { - let r = constraint.refs.first()?; - let entity = document.get_entity(r.entity)?; - let line_midpoint = |line: &acadrust::entities::Line| { - Vector3::new( - (line.start.x + line.end.x) * 0.5, - (line.start.y + line.end.y) * 0.5, - (line.start.z + line.end.z) * 0.5, - ) - }; - 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, - ); - (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::Circle(circle), None | Some(-3)) => { - let center = circle.center_wcs(); - let anchor = circle.point_at_angle_wcs(0.0); - Some((anchor, anchor - center)) - } - (acadrust::EntityType::Arc(arc), None | Some(-3)) => { - let center = arc.center_wcs(); - let anchor = arc.midpoint_wcs(); - Some((anchor, anchor - center)) - } - (acadrust::EntityType::Line(line), Some(marker)) => { - let anchor = resolve_point(entity, marker)?; - let direction = anchor - line_midpoint(line); - Some(( - anchor, - (direction.length_squared() > 1e-24) - .then_some(direction) - .unwrap_or_else(|| line_normal(line)), - )) - } - (acadrust::EntityType::Arc(arc), Some(marker)) => { - let anchor = resolve_point(entity, marker)?; - let direction = anchor - arc.center_wcs(); - Some(( - anchor, - (direction.length_squared() > 1e-24) - .then_some(direction) - .unwrap_or(Vector3::UNIT_Y), - )) - } - (_, Some(marker)) => { - let anchor = resolve_point(entity, marker)?; - Some((anchor, Vector3::UNIT_Y)) - } - _ => None, - } -} - -impl super::Scene { - pub fn is_sketch_constraint_visible(&self, scope: SketchScope, id: ConstraintId) -> bool { - !self.hidden_sketch_constraints.contains(&(scope, id)) - } - - pub fn set_sketch_constraint_visibility( - &mut self, - scope: SketchScope, - handles: Option<&[Handle]>, - dimensional: bool, - visible: bool, - ) -> usize { - let ids: Vec<_> = self.sketch_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(); - for id in &ids { - if visible { self.hidden_sketch_constraints.remove(&(scope, *id)); } - else { self.hidden_sketch_constraints.insert((scope, *id)); } - } - ids.len() - } - - /// Infers relations already present in the selected geometry. - pub fn inferred_sketch_constraints( - &self, - scope: SketchScope, - handles: &[Handle], - ) -> Vec<(ConstraintKind, Vec)> { - use cadkernel::geom2d::{ - infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - SketchPrimitive, 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::Circle(circle)) => SketchPrimitive::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, - }), - _ => continue, - }; - sources.push((*handle, primitive)); - } - let primitives: Vec<_> = sources.iter().map(|(_, primitive)| *primitive).collect(); - let marker = |endpoint| match endpoint { - 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])) - })); - } - mapped.retain(|(kind, refs)| self.validate_sketch_constraint(*kind, refs, None).is_ok()); - mapped - } - - 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) { - (acadrust::EntityType::Spline(spline), target) => (*first, spline, *second, target), - (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())); - 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()?]; - let mut best = (f64::INFINITY, 0, 0); - 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); } - } - } - Some(vec![ - SketchRef::point(spline_handle, best.1 as i32), - SketchRef::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) - } - - /// The constraint set for `scope`, creating an empty one on first use. - /// The `pub(crate)` `sketch_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 { - if let Some(index) = self - .sketch_constraints - .iter() - .position(|s| s.scope == scope) - { - &mut self.sketch_constraints[index] - } else { - self.sketch_constraints - .push(SketchConstraintSet::new(scope)); - self.sketch_constraints.last_mut().expect("just pushed") - } - } - - /// Handle remapping lives in each command that duplicates - /// entities — `Scene::copy_entities`' `handle_map` (COPY/ARRAY/MIRROR, - /// `src/scene/modify.rs`) and `OpenCADStudio::finalize_paste`'s own - /// (clipboard paste, `src/app/command_driver.rs`) — rather than in one - /// shared table, so each call site passes its own `handle_map` here - /// after adding the duplicated entities. - /// - /// For every enabled constraint whose *every* referenced entity was - /// duplicated (a constraint straddling a duplicated and a - /// non-duplicated entity can't sensibly follow — only one side moved), - /// adds an equivalent constraint over the new handles to the same - /// 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( - &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() { - 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 - .refs - .iter() - .map(|r| SketchRef { - entity: handle_map[&r.entity], - marker: r.marker, - }) - .collect(); - to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); - } - } - if to_add.is_empty() { - return; - } - 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); - } - touched.sort(); - touched.dedup(); - let changes: Vec<(Handle, super::ChangeKind)> = touched - .into_iter() - .map(|h| (h, super::ChangeKind::Modified)) - .collect(); - self.bump_entities(&changes); - } - - /// Every persistent constraint, in any scope, currently driven by the - /// named parameter `name` — what the Named Parameters panel's "used by" - /// column shows. `entities` is the constraint's own referenced handles - /// (deduplicated; a two-point constraint on the same entity's own two - /// markers would otherwise list it twice), not resolved against the - /// live document — a caller wanting an entity's current type/position - /// 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 c in &set.constraints { - let Some(DrivingValue::Named(n)) = &c.driving_param else { - continue; - }; - if n != name { - continue; - } - let mut entities: Vec = c.refs.iter().map(|r| r.entity).collect(); - entities.sort(); - entities.dedup(); - out.push(ParameterUsage { - scope: set.scope, - constraint_id: c.id, - kind: c.kind, - entities, - }); - } - } - out - } -} - -/// One persistent constraint driven by a named parameter — [`Scene::parameter_usage`]'s -/// result type. -#[derive(Debug, Clone)] -pub struct ParameterUsage { - pub scope: SketchScope, - pub constraint_id: ConstraintId, - pub kind: ConstraintKind, - pub entities: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn h(v: u64) -> Handle { - Handle::new(v) - } - - #[test] - fn glyph_placement_points_away_from_its_geometry() { - let mut document = acadrust::CadDocument::new(); - let mut line = acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), - ); - line.common.handle = h(1); - document - .add_entity(acadrust::EntityType::Line(line)) - .unwrap(); - let mut circle = acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 5.0); - circle.common.handle = h(2); - document - .add_entity(acadrust::EntityType::Circle(circle)) - .unwrap(); - - let constraint = |reference| SketchConstraint { - id: 0, - kind: ConstraintKind::Fixed, - refs: vec![reference], - driving_param: None, - enabled: true, - }; - let (anchor, direction) = - glyph_placement(&document, &constraint(SketchRef::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(); - 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(); - 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 a = set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], - None, - ); - let b = set.add( - ConstraintKind::Distance, - vec![SketchRef::whole(h(1))], - Some(DrivingValue::Literal(25.0)), - ); - assert_ne!(a, b); - assert_eq!(set.get(a).unwrap().kind, ConstraintKind::Horizontal); - assert_eq!( - set.get(b).unwrap().driving_param, - Some(DrivingValue::Literal(25.0)) - ); - } - - #[test] - fn remove_drops_only_the_matching_id() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let a = set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], - None, - ); - let b = set.add(ConstraintKind::Vertical, vec![SketchRef::whole(h(2))], None); - assert!(set.remove(a)); - assert!( - !set.remove(a), - "removing twice should report nothing removed the second time" - ); - assert!(set.get(a).is_none()); - assert!(set.get(b).is_some()); - } - - #[test] - fn constraints_touching_finds_entity_regardless_of_marker() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add( - ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], - None, - ); - set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], - None, - ); - - let touching_1: Vec<_> = set.constraints_touching(h(1)).collect(); - assert_eq!(touching_1.len(), 1); - let touching_3: Vec<_> = set.constraints_touching(h(3)).collect(); - assert_eq!(touching_3.len(), 1); - assert_eq!(set.constraints_touching(h(99)).count(), 0); - } - - #[test] - fn constraints_touching_skips_disabled() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let id = set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(h(1))], - None, - ); - set.constraints - .iter_mut() - .find(|c| c.id == id) - .unwrap() - .enabled = false; - assert_eq!(set.constraints_touching(h(1)).count(), 0); - } - - #[test] - fn remove_all_touching_drops_every_constraint_referencing_the_entity() { - let mut set = SketchConstraintSet::new(SketchScope::ModelSpace); - let coincident = set.add( - ConstraintKind::Coincident, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(2), 1)], - None, - ); - let horizontal_other = set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(h(3))], - None, - ); - - let removed = set.remove_all_touching(h(1)); - assert_eq!(removed, vec![coincident]); - assert!(set.get(coincident).is_none()); - assert!( - set.get(horizontal_other).is_some(), - "unrelated entity's constraint must survive" - ); - } - - #[test] - fn scope_owner_handle_resolves_block_directly() { - let block_handle = h(42); - let scope = SketchScope::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)); - assert_eq!( - r, - SketchRef { - 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) - .add( - ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], - Some(DrivingValue::Named("gap".to_string())), - ); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Radius, - vec![SketchRef::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) - .add( - ConstraintKind::Radius, - vec![SketchRef::whole(h(3))], - Some(DrivingValue::Literal(5.0)), - ); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Distance, - vec![SketchRef::point(h(4), 0), SketchRef::point(h(4), 1)], - Some(DrivingValue::Named("other".to_string())), - ); - - let usage = scene.parameter_usage("gap"); - assert_eq!( - usage.len(), - 2, - "exactly the two constraints driven by 'gap', got {usage:?}" - ); - assert!(usage - .iter() - .any(|u| u.kind == ConstraintKind::Distance && u.entities == vec![h(1)])); - assert!(usage - .iter() - .any(|u| u.kind == ConstraintKind::Radius && u.entities == vec![h(2)])); - - assert_eq!(scene.parameter_usage("nonexistent").len(), 0); - } - - #[test] - fn parameter_usage_searches_every_scope_not_just_model_space() { - let mut scene = super::super::Scene::new(); - let block = h(99); - scene - .sketch_constraint_set_mut(SketchScope::Block(block)) - .add( - ConstraintKind::Radius, - vec![SketchRef::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)); - } - - #[test] - fn parameter_usage_deduplicates_an_entity_referenced_by_two_markers() { - let mut scene = super::super::Scene::new(); - // A Distance constraint whose two points are both on the same - // entity (e.g. a line's own start and end) must list that entity - // once, not twice. - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Distance, - vec![SketchRef::point(h(1), 0), SketchRef::point(h(1), 1)], - Some(DrivingValue::Named("len".to_string())), - ); - let usage = scene.parameter_usage("len"); - assert_eq!(usage.len(), 1); - assert_eq!(usage[0].entities, vec![h(1)]); - } - - #[test] - fn automatic_inference_maps_relations_and_skips_existing_constraints() { - let mut scene = super::super::Scene::new(); - let first = scene.add_entity(acadrust::EntityType::Line( - acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(5.0, 0.0, 0.0), - ), - )); - let second = scene.add_entity(acadrust::EntityType::Line( - acadrust::entities::Line::from_points( - Vector3::new(5.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(first)], - None, - ); - - let inferred = scene.inferred_sketch_constraints( - SketchScope::ModelSpace, - &[first, second], - ); - - assert!(!inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(first)] - })); - assert!(inferred.iter().any(|(kind, refs)| { - *kind == ConstraintKind::Horizontal && *refs == [SketchRef::whole(second)] - })); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Coincident)); - assert!(inferred.iter().any(|(kind, _)| *kind == ConstraintKind::Colinear)); - } - - #[test] - fn visibility_toggles_geometric_and_dimensional_independently() { - let mut scene = super::super::Scene::new(); - let line = scene.add_entity(acadrust::EntityType::Line( - acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(5.0, 0.0, 0.0), - ), - )); - let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); - let geometric = set.add( - ConstraintKind::Horizontal, - vec![SketchRef::whole(line)], - None, - ); - let dimensional = set.add( - ConstraintKind::Distance, - vec![SketchRef::point(line, 0), SketchRef::point(line, 1)], - Some(DrivingValue::Literal(5.0)), - ); - - assert_eq!( - scene.set_sketch_constraint_visibility( - SketchScope::ModelSpace, - None, - false, - false, - ), - 1 - ); - assert!(!scene.is_sketch_constraint_visible(SketchScope::ModelSpace, geometric)); - assert!(scene.is_sketch_constraint_visible(SketchScope::ModelSpace, dimensional)); - } -} diff --git a/src/scene/sketch_persist.rs b/src/scene/sketch_persist.rs deleted file mode 100644 index 0d76c66b1..000000000 --- a/src/scene/sketch_persist.rs +++ /dev/null @@ -1,391 +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 { - let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { - continue; - }; - // Concatenate every Chunk entry in order, not just the first — - // a set materialized past `MAX_CHUNK_BYTES` spans more than one - // same-code `310` entry (see that constant'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() { - continue; - } - if let Some(set) = decode(&bytes) { - 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/scene/sketch_solve.rs b/src/scene/sketch_solve.rs deleted file mode 100644 index 1562a093c..000000000 --- a/src/scene/sketch_solve.rs +++ /dev/null @@ -1,1684 +0,0 @@ -//! Builds and solves persistent two-dimensional sketch constraints through the geometry kernel. -//! Unsupported entity shapes, invalid target values, and incompatible planes are skipped. - -use std::collections::HashMap; -use std::rc::Rc; - -use acadrust::entities::EntityType; -use acadrust::types::Handle; - -use cadkernel_constraints::constraints::angle_distance::L2LAngle; -use cadkernel_constraints::constraints::circle_arc::{ - ArcLength, C2LDistance, P2CDistance, TangentCircumf, -}; -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, -}; -use cadkernel_constraints::constraints::Constraint; -use cadkernel_constraints::geo::{ - Arc as GArc, Circle as GCircle, 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::{ChangeKind, Scene}; - -/// One referenced entity's geometry, registered into an `cadkernel_constraints::System`'s -/// parameter store. -#[derive(Clone, Copy)] -enum EntityGeom { - Line(GLine), - Circle(GCircle), - /// Full `geo::Arc` — center/radius (via `.circle`), `start_angle`/ - /// `end_angle`, and real `start`/`end` points kept consistent with - /// those by the arc-rules `CurveValue` constraints `solve_scope` adds - /// for every registered arc. See this module's doc comment. - Arc(GArc), - /// Center-and-shape support only — see `register_entity`'s `Ellipse` - /// arm for the acadrust-to-`cadkernel_constraints` parametrization conversion, the - /// ellipse-rules `Difference` constraints `solve_scope` adds for every - /// registered ellipse to keep `focus1` translating rigidly with - /// `center` (this module's doc comment), and that same doc comment for - /// what isn't wired up yet (major/minor axis dimensional constraints, - /// `PointOnEllipse`-based Coincident/Tangent). - Ellipse(GEllipse), -} - -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 - /// read side). - fn point_for_marker(&self, marker: i32) -> Option { - match (self, marker) { - (EntityGeom::Line(l), 0) => Some(l.p1), - (EntityGeom::Line(l), 1) => Some(l.p2), - (EntityGeom::Circle(c), -3) => Some(c.center), - (EntityGeom::Arc(a), 0) => Some(a.start), - (EntityGeom::Arc(a), 1) => Some(a.end), - (EntityGeom::Arc(a), -3) => Some(a.circle.center), - (EntityGeom::Ellipse(e), -3) => Some(e.center), - _ => None, - } - } -} - -/// `Tangent`/`Normal` only ever care about "a whole circle-shaped curve" or -/// "a whole line" — an `Arc` collapses to its `.circle` here exactly like -/// `whole_circle` does, so those two constraints' own inline matches don't -/// need every Circle/Arc combination spelled out separately. -enum CircleOrLine { - Circle(GCircle), - Line(GLine), - Other, -} - -fn as_circle_or_line(g: EntityGeom) -> CircleOrLine { - match g { - EntityGeom::Circle(c) => CircleOrLine::Circle(c), - EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), - EntityGeom::Line(l) => CircleOrLine::Line(l), - EntityGeom::Ellipse(_) => CircleOrLine::Other, - } -} - -/// Reads `handle`'s live geometry and registers it as fresh, free (never -/// `driven`) parameters in `sys` — every referenced entity is solved for, -/// nothing is a priori fixed; per the 2D kernel, an under- -/// constrained scope simply converges to the nearest configuration to its -/// current one, which is the correct behavior for a persistent system where -/// "what stays put" is a property of how many constraints exist, not a -/// convention about which entity was picked first (contrast the one-shot -/// `constrain::apply_*` commands, which do fix the first-picked entity — -/// they solve exactly one constraint in isolation, so they need that -/// convention; a whole scope's constraint graph does not). -fn register_entity( - document: &acadrust::CadDocument, - sys: &mut System, - handle: Handle, -) -> Option { - match document.get_entity(handle)? { - EntityType::Line(l) => { - let p1 = GPoint::new( - sys.add_param(l.start.x, false), - sys.add_param(l.start.y, false), - ); - 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::Circle(c) => { - let center = GPoint::new( - sys.add_param(c.center.x, false), - sys.add_param(c.center.y, false), - ); - let rad = sys.add_param(c.radius, false); - Some(EntityGeom::Circle(GCircle { center, rad })) - } - EntityType::Arc(a) => { - // Full registration: center/radius, both angles, and real - // start/end points seeded from acadrust's own `start_point`/ - // `end_point` (already consistent with center/radius/angle at - // registration time) — `solve_scope` adds the arc-rules - // constraints that keep them that way under solving. See this - // module's doc comment. - let center = GPoint::new( - sys.add_param(a.center.x, false), - sys.add_param(a.center.y, false), - ); - let rad = sys.add_param(a.radius, false); - let start_angle = sys.add_param(a.start_angle, false); - let end_angle = sys.add_param(a.end_angle, false); - let start_seed = a.start_point(); - let end_seed = a.end_point(); - let start = GPoint::new( - sys.add_param(start_seed.x, false), - sys.add_param(start_seed.y, false), - ); - let end = GPoint::new( - sys.add_param(end_seed.x, false), - sys.add_param(end_seed.y, false), - ); - Some(EntityGeom::Arc(GArc { - circle: GCircle { center, rad }, - start, - end, - start_angle, - end_angle, - })) - } - EntityType::Ellipse(el) => { - // acadrust's `Ellipse` is center + major-axis vector (its length - // is the major radius) + minor/major ratio; `cadkernel_constraints::geo:: - // Ellipse` is center + one focus + minor radius. Converting: - // minor_radius = major_radius * ratio, then the focus distance - // c follows from a² = b² + c² (standard ellipse identity), and - // focus1 sits `c` along the major-axis direction from center. - let major_radius = el.major_axis.length(); - let minor_radius = major_radius * el.minor_axis_ratio; - let focus_dist = (major_radius * major_radius - minor_radius * minor_radius) - .max(0.0) - .sqrt(); - // Direction is meaningless once major_radius is ~0 (a - // degenerate point-ellipse) — arbitrarily fall back to +X - // rather than dividing by ~0 in `normalize`. - let unit_major = if major_radius > 1e-9 { - el.major_axis.normalize() - } else { - acadrust::types::Vector3::UNIT_X - }; - let focus1_point = el.center + unit_major * focus_dist; - let center = GPoint::new( - sys.add_param(el.center.x, false), - sys.add_param(el.center.y, false), - ); - let focus1 = GPoint::new( - sys.add_param(focus1_point.x, false), - sys.add_param(focus1_point.y, false), - ); - let radmin = sys.add_param(minor_radius, false); - Some(EntityGeom::Ellipse(GEllipse { - center, - focus1, - radmin, - })) - } - _ => None, - } -} - -/// Resolves one [`SketchRef`] 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. -fn resolve_ref( - document: &acadrust::CadDocument, - sys: &mut System, - cache: &mut HashMap, - r: SketchRef, -) -> Option { - if let Some(&geometry) = cache.get(&r.entity) { - return Some(geometry); - } - let geometry = register_entity(document, sys, r.entity)?; - cache.insert(r.entity, geometry); - Some(geometry) -} - -const PLANE_EPS: f64 = 1e-9; - -fn is_world_z(normal: acadrust::types::Vector3) -> bool { - normal.x.abs() <= PLANE_EPS - && normal.y.abs() <= PLANE_EPS - && (normal.z - 1.0).abs() <= PLANE_EPS -} - -/// Returns the world-XY plane elevation supported by the current 2D bridge. -fn entity_plane_z(entity: &EntityType) -> Option { - let z = match entity { - EntityType::Line(line) - if is_world_z(line.normal) && (line.start.z - line.end.z).abs() <= PLANE_EPS => - { - line.start.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) - if is_world_z(ellipse.normal) && ellipse.major_axis.z.abs() <= PLANE_EPS => - { - ellipse.center.z - } - EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => { - let curve = crate::entities::spline::nurbs3(spline)?; - let first = *curve.control_points().first()?; - if !curve - .control_points() - .iter() - .all(|point| (point[2] - first[2]).abs() <= PLANE_EPS) - { - return None; - } - first[2] - } - EntityType::LwPolyline(polyline) if is_world_z(polyline.normal) => { - let points = super::dimension_assoc::source_points(entity); - let first = *points.first()?; - if !points - .iter() - .all(|point| (point.z - first.z).abs() <= PLANE_EPS) - { - return None; - } - first.z - } - EntityType::Polyline2D(polyline) if is_world_z(polyline.normal) => { - let points = super::dimension_assoc::source_points(entity); - let first = *points.first()?; - if !points - .iter() - .all(|point| (point.z - first.z).abs() <= PLANE_EPS) - { - return None; - } - first.z - } - _ => return None, - }; - z.is_finite().then_some(z) -} - -fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[SketchRef]) -> bool { - let mut plane_z = None; - for reference in refs { - let Some(entity) = document.get_entity(reference.entity) else { - return false; - }; - let Some(z) = entity_plane_z(entity) else { - return false; - }; - if plane_z.is_some_and(|expected: f64| (expected - z).abs() > PLANE_EPS) { - return false; - } - plane_z = Some(z); - } - plane_z.is_some() -} - -fn resolved_target(params: &ParameterTable, constraint: &SketchConstraint) -> 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 - ); - (!must_be_positive || value > 0.0).then_some(value) -} - -/// One [`SketchConstraint`]'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, -/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be -/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape -/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, -/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint -/// that can't be built is simply skipped for this solve, not an error: -/// geometry it would have constrained is left alone, matching how a -/// dangling associative-dimension reference degrades today rather than -/// aborting the whole recompute. (A named-parameter *cycle* should be -/// unreachable here — `ParameterTable::set` already refuses to create one — -/// so it gets the same treatment as any other resolve failure rather than a -/// special case.) -fn build_constraint( - document: &acadrust::CadDocument, - sys: &mut System, - cache: &mut HashMap, - params: &ParameterTable, - c: &SketchConstraint, -) -> 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| match resolve_ref( - document, sys, cache, r, - )? { - EntityGeom::Line(l) => Some(l), - EntityGeom::Circle(_) | EntityGeom::Arc(_) | EntityGeom::Ellipse(_) => None, - }; - let whole_circle = - |sys: &mut System, cache: &mut HashMap<_, _>, r: SketchRef| match resolve_ref( - document, sys, cache, r, - )? { - EntityGeom::Circle(circ) => Some(circ), - EntityGeom::Arc(a) => Some(a.circle), - EntityGeom::Line(_) | EntityGeom::Ellipse(_) => 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) - }; - - // `Coincident`/`Concentric`/`CenterPoint` all solve identically — two - // points (an endpoint, a circle's center via the existing `-3` marker, - // or a plain point) held equal on both axes. Only the DWG-native class - // name and the UI entry point that produces their `refs` differ. - let point_pair_equal = |sys: &mut System, - cache: &mut HashMap<_, _>, - refs: &[SketchRef]| - -> 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 { - return Vec::new(); - }; - vec![ - Rc::new(Equal::new(pa.x, pb.x, 1.0)), - Rc::new(Equal::new(pa.y, pb.y, 1.0)), - ] - }; - - match c.kind { - ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { - point_pair_equal(sys, cache, &c.refs) - } - ConstraintKind::Horizontal => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.y, l.p2.y, 1.0))] - } - ConstraintKind::Vertical => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(l) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(l.p1.x, l.p2.x, 1.0))] - } - ConstraintKind::Parallel => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(fixed), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) - else { - return Vec::new(); - }; - vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] - } - ConstraintKind::Perpendicular => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(fixed), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) - else { - return Vec::new(); - }; - vec![Rc::new(PerpendicularConstraint::new( - sys.store(), - moving, - fixed, - ))] - } - ConstraintKind::Equal => { - 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)) { - return vec![Rc::new(EqualLineLength::new(lb, la))]; - } - let (Some(ca), Some(cb)) = (whole_circle(sys, cache, *a), whole_circle(sys, cache, *b)) - else { - return Vec::new(); - }; - vec![Rc::new(Equal::new(cb.rad, ca.rad, 1.0))] - } - ConstraintKind::EqualDistance => { - let [a, b, c2, d] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(pa), Some(pb), Some(pc), Some(pd)) = ( - point_ref(sys, cache, *a), - point_ref(sys, cache, *b), - point_ref(sys, cache, *c2), - point_ref(sys, cache, *d), - ) else { - return Vec::new(); - }; - vec![Rc::new(EqualLineLength::new( - GLine { p1: pc, p2: pd }, - GLine { p1: pa, p2: pb }, - ))] - } - ConstraintKind::Colinear => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(onto), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) - else { - return Vec::new(); - }; - // Pinning both of `moving`'s endpoints onto `onto`'s infinite - // line forces the two to coincide (as long as `moving`'s own - // two points stay distinct) — no dedicated "colinear" primitive - // needed, `PointOnLine` applied twice does it. - vec![ - Rc::new(PointOnLine::new(moving.p1, onto)), - Rc::new(PointOnLine::new(moving.p2, onto)), - ] - } - ConstraintKind::Midpoint => { - 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 { - return Vec::new(); - }; - vec![ - Rc::new(CenterOfGravity::new( - p.x, - vec![l.p1.x, l.p2.x], - vec![0.5, 0.5], - )), - Rc::new(CenterOfGravity::new( - p.y, - vec![l.p1.y, l.p2.y], - vec![0.5, 0.5], - )), - ] - } - ConstraintKind::PointOnCurve => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(p), Some(geom)) = ( - point_ref(sys, cache, *a), - resolve_ref(document, sys, cache, *b), - ) else { - return Vec::new(); - }; - match geom { - EntityGeom::Line(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))] - } - // Same "distance to the underlying circle is zero" relation - // as the `Circle` arm — doesn't restrict the point to - // within the arc's own sweep, matching that same - // pre-existing simplification for `Circle`. - EntityGeom::Arc(a) => { - let zero = sys.add_param(0.0, true); - vec![Rc::new(P2CDistance::new(a.circle, p, zero))] - } - // `PointOnEllipse` (`cadkernel_constraints::constraints::conic`) isn't - // wired up yet — deliberately deferred alongside - // ellipse-tangency, same as this module's doc comment. - EntityGeom::Ellipse(_) => Vec::new(), - } - } - ConstraintKind::Symmetric => { - let [a, b, m] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(pa), Some(pb), Some(mirror)) = ( - point_ref(sys, cache, *a), - point_ref(sys, cache, *b), - whole_line(sys, cache, *m), - ) else { - return Vec::new(); - }; - let pair = GLine { p1: pa, p2: pb }; - vec![ - Rc::new(MidpointOnLine::new(pair, mirror)), - Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), - ] - } - ConstraintKind::Fixed => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(geom) = resolve_ref(document, sys, cache, *r) else { - return Vec::new(); - }; - match geom { - EntityGeom::Line(l) => { - let (x1, y1, x2, y2) = { - let store = sys.store(); - ( - store.get(l.p1.x), - store.get(l.p1.y), - store.get(l.p2.x), - store.get(l.p2.y), - ) - }; - vec![ - Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), - Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), - Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), - Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), - ] - } - EntityGeom::Circle(circ) => { - let (cx, cy, r) = { - let store = sys.store(); - ( - store.get(circ.center.x), - store.get(circ.center.y), - store.get(circ.rad), - ) - }; - vec![ - Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), - ] - } - // Pinning the 5 intrinsic params (center, radius, both - // angles) is enough — `start`/`end` are already tied to - // those via the arc-rules `CurveValue` constraints - // `solve_scope` adds for every arc, so pinning them too - // would just be redundant. - EntityGeom::Arc(a) => { - let (cx, cy, r, sa, ea) = { - let store = sys.store(); - ( - store.get(a.circle.center.x), - store.get(a.circle.center.y), - store.get(a.circle.rad), - store.get(a.start_angle), - store.get(a.end_angle), - ) - }; - vec![ - Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), - Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), - Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), - ] - } - // Pinning center + radmin is enough — `focus1` is already - // tied to `center` by the ellipse-rules `Difference` - // constraints `solve_scope` adds for every registered - // ellipse (see this module's doc comment), so with `center` - // pinned here too, pinning `focus1` again would just be - // redundant (same reasoning as Arc's `Fixed` arm above). - EntityGeom::Ellipse(el) => { - let (cx, cy, b) = { - let store = sys.store(); - ( - store.get(el.center.x), - store.get(el.center.y), - store.get(el.radmin), - ) - }; - vec![ - Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), - ] - } - } - } - ConstraintKind::Distance => { - let [a, b] = c.refs.as_slice() 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(); - }; - let target = sys.add_param(resolved, true); - vec![Rc::new(P2PDistance::new(pa, pb, target))] - } - ConstraintKind::Angle => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(fixed), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) - else { - return Vec::new(); - }; - let Some(resolved) = resolved_target(params, c) else { - return Vec::new(); - }; - let angle = sys.add_param(resolved.to_radians(), true); - vec![Rc::new(L2LAngle::new(fixed, moving, angle))] - } - ConstraintKind::Radius => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(circle) = whole_circle(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(Equal::new(circle.rad, target, 1.0))] - } - // Same math as `Radius`, just `radius = target / 2` instead of - // `radius = target` — `Equal`'s `ratio` param already supports - // this, matching how the native format stores Diameter as the same - // `ACRADIUSDIAMETERCONSTRAINT` class with a different mode byte - // rather than a distinct one (`dwg_native_constraints.rs`). - ConstraintKind::Diameter => { - let Some(r) = c.refs.first() else { - return Vec::new(); - }; - let Some(circle) = whole_circle(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(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`). - ConstraintKind::DistanceX => { - let [a, b] = c.refs.as_slice() 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(); - }; - let target = sys.add_param(resolved, true); - vec![Rc::new(Difference::new(pa.x, pb.x, target))] - } - ConstraintKind::DistanceY => { - let [a, b] = c.refs.as_slice() 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(); - }; - let target = sys.add_param(resolved, true); - vec![Rc::new(Difference::new(pa.y, pb.y, target))] - } - ConstraintKind::Tangent => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(ga), Some(gb)) = ( - resolve_ref(document, sys, cache, *a), - resolve_ref(document, sys, cache, *b), - ) else { - return Vec::new(); - }; - // An `Arc`'s `.circle` behaves identically to a plain `Circle` - // for tangency math — normalize both refs down first so the - // match below doesn't need every Circle/Arc combination - // written out separately. - match (as_circle_or_line(ga), as_circle_or_line(gb)) { - (CircleOrLine::Circle(c1), CircleOrLine::Circle(c2)) => { - let store = sys.store(); - let (x1, y1, r1) = ( - store.get(c1.center.x), - store.get(c1.center.y), - store.get(c1.rad), - ); - let (x2, y2, r2) = ( - store.get(c2.center.x), - store.get(c2.center.y), - store.get(c2.rad), - ); - let center_dist = ((x1 - x2).powi(2) + (y1 - y2).powi(2)).sqrt(); - // One circle already sits inside the other's span, rather - // than the two side by side — pick internal tangency so - // the first solve doesn't have to cross the singularity - // between the two tangency configurations. - let internal = center_dist < (r1 - r2).abs(); - vec![Rc::new(TangentCircumf::new( - c1.center, c2.center, c1.rad, c2.rad, internal, - ))] - } - (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) - | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { - let store = sys.store(); - let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); - let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); - let (x2, y2) = (store.get(line.p2.x), store.get(line.p2.y)); - // Signed area of (p2-p1) × (center-p1) — same formula - // `C2LDistance::signed_value` itself uses — so `ccw`'s - // sign matches whichever side the circle already sits on. - let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1); - let ccw = area >= 0.0; - // A driven, fixed zero: with `internal: false` this makes - // `C2LDistance`'s target exactly the circle's own radius - // (see its `error_grad`), i.e. plain tangency rather than - // an offset distance. - let zero = sys.add_param(0.0, true); - vec![Rc::new(C2LDistance::new(circ, line, zero, ccw, false))] - } - // Line-Line tangency has no meaning; either side being an - // Ellipse falls here too (`PointOnEllipse`/tangency isn't - // wired up yet). - _ => Vec::new(), - } - } - // Smooth edits the endpoint controls through cadkernel's spatial - // NURBS operation after the ordinary 2D system has settled. - ConstraintKind::Smooth => Vec::new(), - // 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 - // `PointOnCurve` already uses for a point-on-line case. - ConstraintKind::Normal => { - let [a, b] = c.refs.as_slice() else { - return Vec::new(); - }; - let (Some(ga), Some(gb)) = ( - resolve_ref(document, sys, cache, *a), - resolve_ref(document, sys, cache, *b), - ) else { - return Vec::new(); - }; - match (as_circle_or_line(ga), as_circle_or_line(gb)) { - (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) - | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { - vec![Rc::new(PointOnLine::new(circ.center, line))] - } - // Line-Line has no meaning here (that's `Perpendicular`); - // Circle-Circle "normal" (orthogonal circles) needs a - // different, not-yet-implemented relation. - _ => 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))] - } - } -} - -fn smooth_target_jet(entity: &EntityType, marker: i32) -> Option { - let parameter = match marker { - 0 => 0.0, - 1 => 1.0, - _ => return None, - }; - let curve = crate::entities::curve::entity_curve(entity)?; - if curve.is_closed() { - return None; - } - let step = if marker == 0 { 1.0e-4 } else { -1.0e-4 }; - let point = curve.point_at(parameter); - let tangent = curve.tangent_at(parameter); - let next = curve.point_at(parameter + step); - let third = curve.point_at(parameter + 2.0 * step); - Some(cadkernel::space::CurveJet { - point, - tangent, - curvature: cadkernel::space::curve::curvature_through(point, next, third), - }) -} - -fn apply_smooth_constraints( - document: &acadrust::CadDocument, - set: &SketchConstraintSet, - results: &mut Vec<(Handle, EntityType)>, -) { - for constraint in set - .constraints - .iter() - .filter(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth) - { - let [source_ref, target_ref] = constraint.refs.as_slice() else { - continue; - }; - let source = results - .iter() - .find(|(handle, _)| *handle == source_ref.entity) - .map(|(_, entity)| entity.clone()) - .or_else(|| document.get_entity(source_ref.entity).cloned()); - let target = results - .iter() - .find(|(handle, _)| *handle == target_ref.entity) - .map(|(_, entity)| entity.clone()) - .or_else(|| document.get_entity(target_ref.entity).cloned()); - let (Some(EntityType::Spline(mut spline)), Some(target)) = (source, target) else { - continue; - }; - let endpoint = match source_ref.marker { - Some(0) => cadkernel::space::SplineEnd::Start, - Some(1) => cadkernel::space::SplineEnd::End, - _ => continue, - }; - let Some(target_marker) = target_ref.marker else { - continue; - }; - let Some(target_jet) = smooth_target_jet(&target, target_marker) else { - continue; - }; - let Some(curve) = crate::entities::spline::nurbs3(&spline) else { - continue; - }; - let Some(smoothed) = cadkernel::space::smooth_nurbs_endpoint(&curve, endpoint, target_jet) - else { - continue; - }; - crate::entities::spline::replace_with_nurbs(&mut spline, &smoothed); - let updated = EntityType::Spline(spline); - if let Some((_, entity)) = results - .iter_mut() - .find(|(handle, _)| *handle == source_ref.entity) - { - *entity = updated; - } else { - results.push((source_ref.entity, updated)); - } - } -} - -const MOVE_EPS: f64 = 1e-9; -type SolveResult = ( - Vec<(Handle, EntityType)>, - usize, - Vec<( - ConstraintId, - cadkernel_constraints::diagnosis::RedundancyKind, - )>, -); - -/// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document -/// geometry, solves it, and returns the resulting entity states for every -/// handle whose registered coordinates actually moved beyond floating-point -/// noise, plus the scope's total remaining degrees of freedom and any -/// redundant/conflicting constraints, resolved -/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer -/// to remove. `None` if nothing in the scope could be built (no constraint -/// resolved to anything). DOF/conflicts are still returned even when every -/// subsystem fails to solve (rank is a structural property of the Jacobian, -/// not of whether Dogleg converged) — only the geometry write-back is gated -/// on a successful solve. -fn solve_scope( - document: &acadrust::CadDocument, - params: &ParameterTable, - set: &SketchConstraintSet, -) -> Option { - 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 - // `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 - // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after - // partitioning resolves a row back to the `ConstraintId` the UI - // actually names. - let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); - - for c in &set.constraints { - for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { - owner.push((constraint.clone(), c.id)); - sys.add_constraint(constraint); - } - } - - let has_smooth = set - .constraints - .iter() - .any(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth); - if cache.is_empty() && !has_smooth { - return None; - } - - // 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 - // 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. - for geom in cache.values() { - let EntityGeom::Arc(arc) = geom else { continue }; - let curve = Rc::new(*arc); - sys.add_constraint(Rc::new(CurveValue::new( - arc.start, - arc.start.x, - curve.clone(), - arc.start_angle, - ))); - sys.add_constraint(Rc::new(CurveValue::new( - arc.start, - arc.start.y, - curve.clone(), - arc.start_angle, - ))); - sys.add_constraint(Rc::new(CurveValue::new( - arc.end, - arc.end.x, - curve.clone(), - arc.end_angle, - ))); - sys.add_constraint(Rc::new(CurveValue::new( - arc.end, - arc.end.y, - curve, - arc.end_angle, - ))); - } - - // Ellipse rules (same motivation as arc rules, different mechanism): - // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an - // offset from `center`. If some constraint (e.g. Concentric) pulls only - // on `center` and nothing references `focus1`/`radmin`, the solver - // correctly leaves `focus1`'s *absolute* coordinates untouched — but - // `center` moved and `focus1` didn't, so the derived vector - // `focus1 - center` (which is what `major_axis` direction/length and - // `minor_axis_ratio` are actually computed from on write-back) changes - // anyway, making the ellipse appear to rotate and reshape even though - // no shape parameter was itself pulled on. Pinning `focus1 - center` to - // its pre-solve (seed) value forces `focus1` to translate rigidly with - // `center`, keeping orientation/eccentricity invariant unless something - // actually constrains `focus1`/`radmin` directly (only `Fixed` does - // today). `radmin` needs no equivalent rule — as a lone scalar it isn't - // coupled to `center`'s movement, so it already stays put on its own. - for geom in cache.values() { - let EntityGeom::Ellipse(el) = geom else { - continue; - }; - let (cx, cy, fx, fy) = { - let store = sys.store(); - ( - store.get(el.center.x), - store.get(el.center.y), - store.get(el.focus1.x), - store.get(el.focus1.y), - ) - }; - let dx = sys.add_param(fx - cx, true); - let dy = sys.add_param(fy - cy, true); - sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); - sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); - } - - let partitions = sys.partition(); - for sub in &partitions { - solve_dl(sub, sys.store_mut()); - } - // `System::partition` only includes parameters referenced by a - // constraint, so an otherwise registered but unconstrained coordinate (e.g. a - // line's X after only a Horizontal constraint pins its Ys) is invisible - // to `diagnose` entirely, undercounting DOF. Every such untouched free - // param is unconstrained on its own, i.e. exactly 1 DOF each — added - // back here rather than fixed in `cadkernel_constraints::diagnosis`, which correctly - // has no opinion on params outside the `SubSystem` it was handed. - let total_free: usize = cache - .values() - .map(|g| match g { - EntityGeom::Line(_) => 4, - EntityGeom::Circle(_) => 3, - // Raw param count (center×2, rad, start×2, end×2, both - // angles) — same "raw, not netted against its own - // constraints" convention as every other arm here. The four - // arc-rules `CurveValue` constraints (always present, added - // just above) already reduce this to 5 *effective* DOF through - // the normal `touched_free`/`diag.dof` accounting below, the - // same way any other constraint would. - EntityGeom::Arc(_) => 9, - EntityGeom::Ellipse(_) => 5, - }) - .sum(); - let touched_free: usize = partitions.iter().map(|s| s.p_size()).sum(); - let mut dof = total_free.saturating_sub(touched_free); - let mut conflicts: Vec<( - ConstraintId, - cadkernel_constraints::diagnosis::RedundancyKind, - )> = Vec::new(); - for sub in &partitions { - let diag = cadkernel_constraints::diagnosis::diagnose(sub, sys.store()); - dof += diag.dof; - 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. - for (row, kind) in - cadkernel_constraints::diagnosis::classify_redundant(sub, sys.store(), &diag.redundant) - { - let row_constraint = &sub.constraints()[row]; - if let Some(&(_, id)) = owner.iter().find(|(rc, _)| Rc::ptr_eq(rc, row_constraint)) { - conflicts.push((id, kind)); - } - } - } - - let store = sys.store(); - let mut results = Vec::new(); - for (&handle, geom) in &cache { - let Some(entity) = document.get_entity(handle) else { - continue; - }; - match (entity, geom) { - (EntityType::Line(l), EntityGeom::Line(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), - ); - if (x1 - l.start.x).abs() > MOVE_EPS - || (y1 - l.start.y).abs() > MOVE_EPS - || (x2 - l.end.x).abs() > MOVE_EPS - || (y2 - l.end.y).abs() > MOVE_EPS - { - let mut updated = l.clone(); - updated.start.x = x1; - updated.start.y = y1; - updated.end.x = x2; - updated.end.y = y2; - results.push((handle, EntityType::Line(updated))); - } - } - (EntityType::Circle(c), EntityGeom::Circle(g)) => { - let (cx, cy, r) = ( - store.get(g.center.x), - store.get(g.center.y), - store.get(g.rad), - ); - if (cx - c.center.x).abs() > MOVE_EPS - || (cy - c.center.y).abs() > MOVE_EPS - || (r - c.radius).abs() > MOVE_EPS - { - let mut updated = c.clone(); - updated.center.x = cx; - updated.center.y = cy; - updated.radius = r; - results.push((handle, EntityType::Circle(updated))); - } - } - (EntityType::Arc(a), EntityGeom::Arc(g)) => { - // Center/radius/angles only — `start`/`end` are derived - // (acadrust's `Arc` has no separate stored fields for them; - // `start_point()`/`end_point()` compute them from these - // same four), so nothing further needs writing back for - // them specifically. - let (cx, cy, r) = ( - store.get(g.circle.center.x), - store.get(g.circle.center.y), - store.get(g.circle.rad), - ); - let (new_start_angle, new_end_angle) = - (store.get(g.start_angle), store.get(g.end_angle)); - if (cx - a.center.x).abs() > MOVE_EPS - || (cy - a.center.y).abs() > MOVE_EPS - || (r - a.radius).abs() > MOVE_EPS - || (new_start_angle - a.start_angle).abs() > MOVE_EPS - || (new_end_angle - a.end_angle).abs() > MOVE_EPS - { - let mut updated = a.clone(); - updated.center.x = cx; - updated.center.y = cy; - updated.radius = r; - updated.start_angle = new_start_angle; - updated.end_angle = new_end_angle; - results.push((handle, EntityType::Arc(updated))); - } - } - (EntityType::Ellipse(el), EntityGeom::Ellipse(g)) => { - // Converting back to acadrust's center + major-axis-vector + - // minor/major-ratio parametrization — the inverse of - // `register_entity`'s `Ellipse` arm. `rad_maj_at` (already - // provided by `cadkernel_constraints::geo::Ellipse`) does the a²=b²+c² - // algebra; only the major-axis *direction* needs deriving - // here, from the (possibly moved) focus relative to center. - let (cx, cy) = (store.get(g.center.x), store.get(g.center.y)); - let (fx, fy) = (store.get(g.focus1.x), store.get(g.focus1.y)); - let minor_radius = store.get(g.radmin); - let (major_radius, _) = g.rad_maj_at(store, None); - let focus_dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt(); - // Direction is ill-defined once the ellipse is (near) - // circular — keep whatever direction it already had rather - // than snapping to an arbitrary axis. - let unit_major = if focus_dist > 1e-9 { - acadrust::types::Vector3::new(fx - cx, fy - cy, 0.0).normalize() - } else { - el.major_axis.normalize() - }; - let new_major_axis = unit_major * major_radius; - let new_ratio = if major_radius > 1e-9 { - minor_radius / major_radius - } else { - 0.0 - }; - if (cx - el.center.x).abs() > MOVE_EPS - || (cy - el.center.y).abs() > MOVE_EPS - || (new_major_axis.x - el.major_axis.x).abs() > MOVE_EPS - || (new_major_axis.y - el.major_axis.y).abs() > MOVE_EPS - || (new_ratio - el.minor_axis_ratio).abs() > MOVE_EPS - { - let mut updated = el.clone(); - updated.center.x = cx; - updated.center.y = cy; - updated.major_axis = new_major_axis; - updated.minor_axis_ratio = new_ratio; - results.push((handle, EntityType::Ellipse(updated))); - } - } - _ => {} - } - } - apply_smooth_constraints(document, set, &mut results); - Some((results, dof, conflicts)) -} - -impl Scene { - /// Rejects constraints the 2D kernel bridge cannot represent before they - /// are persisted or reported as applied. - pub(crate) fn validate_sketch_constraint( - &self, - kind: ConstraintKind, - refs: &[SketchRef], - driving_param: Option<&super::named_parameters::DrivingValue>, - ) -> Result<(), &'static str> { - if !refs_share_supported_plane(&self.document, refs) { - return Err( - "Constraint references must be supported entities on the same world-XY plane.", - ); - } - if kind == ConstraintKind::Smooth { - let [source_ref, target_ref] = refs else { - return Err("Smooth requires one spline endpoint and one target endpoint."); - }; - if source_ref.entity == target_ref.entity { - return Err("Smooth requires two different entities."); - } - let Some(EntityType::Spline(spline)) = self.document.get_entity(source_ref.entity) - else { - return Err("Smooth requires an open spline as its first reference."); - }; - if spline.flags.closed || spline.flags.periodic { - return Err("Smooth requires an open spline."); - } - let endpoint = match source_ref.marker { - Some(0) => cadkernel::space::SplineEnd::Start, - Some(1) => cadkernel::space::SplineEnd::End, - _ => return Err("Smooth requires a spline endpoint."), - }; - let Some(target_marker) = target_ref.marker else { - return Err("Smooth requires a target endpoint."); - }; - let Some(target) = self.document.get_entity(target_ref.entity) else { - return Err("Smooth target does not exist."); - }; - let Some(target_jet) = smooth_target_jet(target, target_marker) else { - return Err("The selected target does not support endpoint smoothing."); - }; - let Some(curve) = crate::entities::spline::nurbs3(spline) else { - return Err("The selected spline cannot be evaluated."); - }; - if cadkernel::space::smooth_nurbs_endpoint(&curve, endpoint, target_jet).is_none() { - return Err("The selected spline cannot satisfy curvature continuity."); - } - return Ok(()); - } - let validation_target = match driving_param { - Some(super::named_parameters::DrivingValue::Named(name)) - if !self.named_parameters.contains(name) => - { - Some(super::named_parameters::DrivingValue::Literal(1.0)) - } - value => value.cloned(), - }; - let candidate = SketchConstraint { - id: 0, - kind, - refs: refs.to_vec(), - driving_param: validation_target, - enabled: true, - }; - let mut system = System::new(); - let mut cache = HashMap::new(); - if build_constraint( - &self.document, - &mut system, - &mut cache, - &self.named_parameters, - &candidate, - ) - .is_empty() - { - return Err("The selected entities or target value do not support this constraint."); - } - Ok(()) - } - - /// Re-solves every constraint scope touched by `changes`. Returns - /// 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( - &mut self, - changes: &[(Handle, ChangeKind)], - ) -> Vec<(Handle, ChangeKind)> { - // An erased entity takes its constraints with it instead of leaving - // dangling references. Done first, - // and unconditionally over every scope (not just ones a `touched` - // check would catch), so a scope left with zero constraints after - // this doesn't attempt a pointless resolve below. - // - // 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`/ - // `document.objects` and neither of those directories ever saw this - // mutation) — one undo press now restores both the entity and its - // constraints together. - for (handle, kind) in changes { - if *kind != ChangeKind::Removed { - continue; - } - for i in 0..self.sketch_constraints.len() { - if self.sketch_constraints[i] - .constraints_touching(*handle) - .next() - .is_some() - { - 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 mut result = Vec::new(); - for i in 0..self.sketch_constraints.len() { - let touched = changes.iter().any(|(handle, _)| { - self.sketch_constraints[i] - .constraints_touching(*handle) - .next() - .is_some() - }); - if !touched { - continue; - } - let Some((solved, dof, conflicts)) = solve_scope( - &self.document, - &self.named_parameters, - &self.sketch_constraints[i], - ) else { - continue; - }; - self.sketch_constraints[i].dof = Some(dof); - self.sketch_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)); - } - if let Some(slot) = self.document.get_entity_mut(handle) { - *slot = new_entity; - } - result.push((handle, ChangeKind::Modified)); - } - } - result - } - - /// 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( - &self, - touched: &[Handle], - ) -> Vec<(Handle, EntityType)> { - if self.sketch_constraints.is_empty() || touched.is_empty() { - return Vec::new(); - } - let mut result = Vec::new(); - for set in &self.sketch_constraints { - let is_touched = touched - .iter() - .any(|handle| set.constraints_touching(*handle).next().is_some()); - if !is_touched { - continue; - } - let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) - else { - continue; - }; - result.extend(solved); - } - result - } -} - -#[cfg(test)] -mod tests { - use super::super::named_parameters::DrivingValue; - use super::super::sketch_constraints::{ - ConstraintKind, SketchConstraintSet, SketchRef, SketchScope, - }; - use super::Scene; - use acadrust::entities::EntityType; - use acadrust::types::Vector3; - - #[test] - fn validation_rejects_unsupported_entity_shapes_and_planes() { - let mut scene = Scene::new(); - let circle = scene.add_entity(EntityType::Circle( - acadrust::entities::Circle::from_center_radius(Vector3::ZERO, 2.0), - )); - assert!(scene - .validate_sketch_constraint( - ConstraintKind::Horizontal, - &[SketchRef::whole(circle)], - None - ) - .is_err()); - - let lower = 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 upper = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 1.0), - Vector3::new(1.0, 0.0, 1.0), - ))); - assert!(scene - .validate_sketch_constraint( - ConstraintKind::Parallel, - &[SketchRef::whole(lower), SketchRef::whole(upper)], - None, - ) - .is_err()); - } - - #[test] - fn validation_enforces_dimensional_value_domains() { - let mut scene = Scene::new(); - let line = 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 refs = [SketchRef::point(line, 0), SketchRef::point(line, 1)]; - assert!(scene - .validate_sketch_constraint( - ConstraintKind::Distance, - &refs, - Some(&DrivingValue::Literal(-1.0)), - ) - .is_err()); - assert!(scene - .validate_sketch_constraint( - ConstraintKind::DistanceX, - &refs, - Some(&DrivingValue::Literal(-1.0)), - ) - .is_ok()); - assert!(scene - .validate_sketch_constraint( - ConstraintKind::DistanceX, - &refs, - Some(&DrivingValue::Literal(f64::NAN)), - ) - .is_err()); - } - - // 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. - // This one test needs `record_undo_before`/`take_undo_recording` - // (`pub(crate)`), so it stays internal. - #[test] - fn one_edit_that_ripples_through_a_constraint_still_records_as_one_undo_step() { - 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 = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add( - ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], - None, - ); - scene.sketch_constraints.push(set); - - scene.begin_undo_recording(); - // A real command (Move, grip-drag commit, ...) records its own - // before-image before mutating. - let before_a = scene.document.get_entity_arc(a); - scene.record_undo_before(a, before_a); - if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { - l.end = Vector3::new(10.0, 6.0, 0.0); - } - scene.bump_entities(&[(a, super::ChangeKind::Modified)]); - let recording = scene - .take_undo_recording() - .expect("an undo recording should still be open"); - - let (entities, _objects, _sketch_constraints, _named_parameters) = - recording.into_recorded_images(); - let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); - assert!( - touched.contains(&a), - "the directly-edited line must be in the undo delta" - ); - assert!( - touched.contains(&b), - "the constraint-solved neighbor must ride the same undo delta" - ); - } - - /// Erasing an entity records the removed constraints in the same undo - /// transaction as the geometry. - #[test] - fn erasing_a_constrained_entity_records_its_scope_for_undo() { - 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(10.0, 0.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, - ); - assert_eq!( - scene - .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap() - .constraints - .len(), - 1 - ); - - scene.begin_undo_recording(); - scene.erase_entities(&[a]); - - // Ensure the test exercises restoration rather than a no-op. - assert_eq!( - scene - .sketch_constraint_set(SketchScope::ModelSpace) - .map_or(0, |s| s.constraints.len()), - 0, - "the constraint touching the erased line should be gone from the live set" - ); - - let recording = scene - .take_undo_recording() - .expect("an undo recording should still be open"); - let (_entities, _objects, sketch_constraints, _named_parameters) = - recording.into_recorded_images(); - assert_eq!( - sketch_constraints.len(), - 1, - "the touched scope's before-image must be captured" - ); - let (scope, before) = &sketch_constraints[0]; - assert_eq!(*scope, SketchScope::ModelSpace); - assert_eq!( - before.constraints.len(), - 1, - "the before-image must still hold the constraint as it was before the erase" - ); - assert_eq!(before.constraints[0].kind, ConstraintKind::Coincident); - assert_eq!( - before.constraints[0].refs, - vec![SketchRef::point(a, 1), SketchRef::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(); - assert_eq!( - scene - .sketch_constraint_set(SketchScope::ModelSpace) - .unwrap() - .constraints - .len(), - 1, - "restoring the before-image must bring the constraint back" - ); - } - - /// `solve_sketch_constraints_preview` reports the constrained - /// neighbor's solved position without writing anything into the - /// document itself — the grip-drag caller owns applying it. - #[test] - fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() { - 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 = SketchConstraintSet::new(SketchScope::ModelSpace); - set.add( - ConstraintKind::Parallel, - vec![SketchRef::whole(a), SketchRef::whole(b)], - None, - ); - scene.sketch_constraints.push(set); - - // Simulate a live grip drag: mutate `a` directly (as `apply_grip` - // would each frame), without going through `bump_entities` at all. - if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { - l.end = Vector3::new(10.0, 6.0, 0.0); - } - let b_before = scene.document.get_entity(b).cloned(); - - let solved = scene.solve_sketch_constraints_preview(&[a]); - - assert_eq!( - scene.document.get_entity(b), - b_before.as_ref(), - "preview must not mutate the document" - ); - // The whole scope solves together (every registered entity's params - // are free, not just `b`'s — see `register_entity`'s doc comment), - // so `a` itself may also have shifted slightly to reach the nearest - // mutually-parallel configuration; read whichever position `a` ends - // up at from `solved` too, rather than assuming it stayed exactly - // where the simulated drag put it. - let line_dir = |entity: &EntityType| { - let EntityType::Line(l) = entity else { - panic!("expected a Line") - }; - (l.end.x - l.start.x, l.end.y - l.start.y) - }; - let dir_a = solved - .iter() - .find(|(h, _)| *h == a) - .map(|(_, e)| line_dir(e)) - .unwrap_or((10.0, 6.0)); - let (_, moved_entity) = solved - .iter() - .find(|(h, _)| *h == b) - .expect("b should be reported as moved"); - let dir_b = line_dir(moved_entity); - let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; - assert!( - cross.abs() < 1e-6, - "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" - ); - } - - #[test] - fn smooth_constraint_recomputes_the_spline_after_its_target_moves() { - let mut scene = Scene::new(); - let target = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(5.0, 0.0, 0.0), - ))); - let mut spline = acadrust::entities::Spline::new(); - spline.degree = 3; - spline.control_points = vec![ - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(1.0, 2.0, 0.0), - Vector3::new(3.0, 2.0, 0.0), - Vector3::new(5.0, 3.0, 0.0), - ]; - spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); - let spline = scene.add_entity(EntityType::Spline(spline)); - scene - .sketch_constraint_set_mut(SketchScope::ModelSpace) - .add( - ConstraintKind::Smooth, - vec![SketchRef::point(spline, 0), SketchRef::point(target, 0)], - None, - ); - - if let Some(EntityType::Line(line)) = scene.document.get_entity_mut(target) { - line.start.y = 1.0; - line.end.y = 1.0; - } - scene.bump_entities(&[(target, super::ChangeKind::Modified)]); - - let EntityType::Spline(spline) = scene.document.get_entity(spline).unwrap() else { - panic!("expected spline"); - }; - let curve = crate::entities::spline::nurbs3(spline).unwrap(); - let jet = cadkernel::space::CurveJet::from_nurbs( - &curve, - cadkernel::space::SplineEnd::Start, - ) - .unwrap(); - assert!((jet.point[0] - 0.0).abs() < 1e-9); - assert!((jet.point[1] - 1.0).abs() < 1e-9); - assert!(jet.tangent[1].abs() < 1e-9, "jet={jet:?}"); - assert!(cadkernel::space::Vec3::from(jet.curvature).length() < 5e-3); - } -} diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index a28b2f98f..86a8e5ea4 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -80,6 +80,25 @@ pub fn properties_sectioned( } else { sections.extend(groups); } + if let Some(thickness) = entity_thickness(entity) { + let already_present = sections.iter().flat_map(|section| §ion.props).any(|property| { + property.field == "thickness" + || property.field == "sl_thickness" + || property.label == crate::t!("Thickness").as_ref() + }); + if !already_present { + if let Some(geometry) = sections + .iter_mut() + .find(|section| section.title == crate::t!("Geometry").as_ref()) + { + geometry.props.push(crate::entities::common::edit_prop( + crate::t!("Thickness").as_ref(), + "thickness", + thickness, + )); + } + } + } sections } @@ -252,9 +271,8 @@ pub fn set_entity_xdata( } /// The extrusion thickness (DXF 39) of the entities that carry one, or `None` -/// for entity types that have none. Thickness is a per-entity field but is -/// surfaced in the General group (as in a standard properties palette), so -/// this bridges the two. +/// for entity types that have none. The Properties panel uses this to append +/// the shared field to each entity's Geometry group. pub fn entity_thickness(entity: &EntityType) -> Option { Some(match entity { EntityType::Arc(e) => e.thickness, @@ -382,3 +400,26 @@ pub fn grip_menu_point_value( pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) { EntityTypeOps::apply_transform(entity, t); } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + + #[test] + fn line_thickness_follows_the_entity_geometry_rows() { + let mut line = acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + line.thickness = 2.5; + let sections = properties_sectioned(Handle::NULL, &EntityType::Line(line), &[]); + + assert!(!sections[0] + .props + .iter() + .any(|property| property.field == "thickness")); + let geometry = sections + .iter() + .find(|section| section.props.iter().any(|property| property.field == "start_x")) + .unwrap(); + assert_eq!(geometry.props.last().unwrap().field, "thickness"); + } +} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 6f8966578..82bfd2eb7 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3692,7 +3692,10 @@ impl Scene { &self, inst: &ViewportInstance, ) -> Arc> { - if self.selected.is_empty() && self.hover_highlight.is_none() { + if self.selected.is_empty() + && self.hover_highlight.is_none() + && self.constraint_hover_highlights.is_empty() + { return Arc::new(Vec::new()); } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index a578fa99d..60ad63211 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -84,6 +84,23 @@ fn strip_option_listing(s: &str) -> String { out.replace(" :", ":").trim().to_string() } +// Font swaps must preserve U+276F, U+24D8, and U+2715. +const COMMAND_PREFIX: &str = "❯ "; +const INFO_PREFIX: &str = "ⓘ "; +const ERROR_PREFIX: &str = "✕ "; + +fn format_info(msg: &str) -> String { + if msg.starts_with(INFO_PREFIX) { + msg.to_string() + } else { + format!("{INFO_PREFIX}{msg}") + } +} + +fn format_error(msg: &str) -> String { + format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase()) +} + const MAX_HISTORY: usize = 64; #[derive(Clone)] @@ -335,7 +352,10 @@ impl CommandLine { } pub fn push_command(&mut self, cmd: &str) { - self.push(EntryKind::Command, format!("{} {cmd}", t!("Command:"))); + self.push( + EntryKind::Command, + format!("{COMMAND_PREFIX}{} {cmd}", t!("Command:")), + ); } pub fn push_output(&mut self, msg: &str) { self.push(EntryKind::Output, msg.to_string()); @@ -343,7 +363,7 @@ impl CommandLine { pub fn push_error(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid"))); + self.push(EntryKind::Error, format_error(msg)); } /// Append an error unless it is already the latest history line. Repeated /// retry failures should refresh the concise message, not flood history @@ -352,7 +372,7 @@ impl CommandLine { pub fn push_error_once(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - let text = format!("*{}* {msg}", t!("Invalid")); + let text = format_error(msg); if let Some(last) = self .history .last_mut() @@ -372,12 +392,7 @@ impl CommandLine { self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning"))); } pub fn push_info(&mut self, msg: &str) { - let text = if msg.starts_with("i ") { - msg.to_string() - } else { - format!("i {msg}") - }; - self.push(EntryKind::Info, text); + self.push(EntryKind::Info, format_info(msg)); } fn push(&mut self, kind: EntryKind, text: String) { self.history.push(HistoryEntry { @@ -407,11 +422,7 @@ impl CommandLine { e.created_at = Instant::now(); } if let Some(p) = &prompt { - let formatted = if p.starts_with("i ") { - p.clone() - } else { - format!("i {p}") - }; + let formatted = format_info(p); // Reuse the prompt line dispatch/step-transition just pushed; // otherwise add it. if self.history.last().map(|e| &e.text) != Some(&formatted) { @@ -583,13 +594,18 @@ impl CommandLine { .iter() .fold(column![].spacing(0), |col, entry| { let kind = entry.kind.clone(); + let is_error = kind == EntryKind::Error; let entry_text = |value: String| { - text(value).size(11).style({ + let mut txt = text(value).size(11).style({ let kind = kind.clone(); move |theme: &Theme| iced::widget::text::Style { color: Some(history_color(theme, &kind)), } - }) + }); + if is_error { + txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)); + } + txt }; // The current step's prompt is the single pinned line. When the // step offers options, render them as clickable buttons inline @@ -1041,13 +1057,19 @@ fn history_highlight_format( ) -> iced::advanced::text::highlighter::Format { iced::advanced::text::highlighter::Format { color: Some(history_color(theme, kind)), - font: None, + font: match kind { + EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)), + _ => None, + }, } } #[cfg(test)] mod tests { - use super::{mcp_status, ranked_matches, CommandLine}; + use super::{ + format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind, + ERROR_PREFIX, INFO_PREFIX, + }; use crate::t; use rustc_hash::FxHashMap; @@ -1121,13 +1143,81 @@ mod tests { assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}"); } + #[test] + fn history_markers_and_copy_text_are_clean() { + let mut line = CommandLine::new(); + line.clear_history(); + let command = "LINE"; + let info = "Object selected."; + let error = "Unable to save: file is in use."; + let output = "12.5, 7.0\nraw output"; + + line.push_command(command); + line.push_info(info); + line.push_error(error); + line.push_output(output); + + let expected = vec![ + format!("❯ {} {command}", t!("Command:")), + format!("{INFO_PREFIX}{info}"), + format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()), + output.to_string(), + ]; + let actual = line + .history + .iter() + .map(|entry| entry.text.as_str()) + .collect::>(); + assert_eq!( + actual, + expected.iter().map(String::as_str).collect::>() + ); + assert_eq!( + line.history + .iter() + .map(|entry| entry.kind.clone()) + .collect::>(), + vec![ + EntryKind::Command, + EntryKind::Info, + EntryKind::Error, + EntryKind::Output, + ] + ); + + let plain = line.history_plain_text(); + assert!(!plain.contains('*')); + assert_eq!(plain, expected.join("\n")); + assert_eq!(line.history[3].text.as_bytes(), output.as_bytes()); + } + + #[test] + fn error_history_uses_bold_highlight_format() { + let theme = iced::Theme::Light; + let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold); + assert_eq!( + history_highlight_format(&EntryKind::Error, &theme).font, + Some(bold) + ); + for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] { + assert_eq!(history_highlight_format(&kind, &theme).font, None); + } + } + #[test] fn issue_498_repeated_save_error_is_not_duplicated() { let mut line = CommandLine::new(); let initial_len = line.history.len(); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); + let message = t!("Unable to save: file is in use."); + let expected_error = format_error(message.as_ref()); + line.push_error_once(message.as_ref()); + line.push_error_once(message.as_ref()); assert_eq!(line.history.len(), initial_len + 1); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some(expected_error.as_str()) + ); + assert!(!line.history_plain_text().contains('*')); } #[test] @@ -1193,8 +1283,14 @@ mod tests { fn info_entries_carry_prefix_marker() { let mut line = CommandLine::new(); line.push_info("Object selected."); - assert_eq!(line.history.last().map(|e| e.text.as_str()), Some("i Object selected.")); - assert_eq!(line.history.last().map(|e| &e.kind), Some(&super::EntryKind::Info)); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some("ⓘ Object selected.") + ); + assert_eq!( + line.history.last().map(|entry| &entry.kind), + Some(&EntryKind::Info) + ); } #[test] @@ -1204,9 +1300,13 @@ mod tests { line.push_info("Specify first point:"); assert_eq!(line.history.len(), initial_len + 1); line.set_step_prompt(Some("Specify first point:".to_string())); - assert_eq!(line.history.len(), initial_len + 1, "prompt must reuse push_info entry without duplicating"); + assert_eq!( + line.history.len(), + initial_len + 1, + "prompt must reuse push_info entry without duplicating" + ); let last = line.history.last().unwrap(); assert!(last.pinned); - assert_eq!(last.text, "i Specify first point:"); + assert_eq!(last.text, "ⓘ Specify first point:"); } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4cd3418d2..001dc39d5 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -27,6 +27,7 @@ const CONSTRAINT_GLYPH_PAD_X: f32 = 7.0; const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0; const CONSTRAINT_GLYPH_GAP: f32 = 6.0; const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0; +const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0; fn constraint_glyph_size(label: &str) -> Size { let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62 @@ -35,6 +36,22 @@ fn constraint_glyph_size(label: &str) -> Size { Size::new(w, h) } +fn draw_tangent_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + color: Color, +) { + let radius = 4.5; + let circle_center = Point::new(center.x - 0.3, center.y + 2.75); + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let contact = Point::new(circle_center.x - diagonal, circle_center.y - diagonal); + let tangent_end = Point::new(contact.x + 8.0, contact.y - 8.0); + let stroke = canvas::Stroke::default().with_color(color).with_width(1.35); + + frame.stroke(&canvas::Path::circle(circle_center, radius), stroke.clone()); + frame.stroke(&canvas::Path::line(contact, tangent_end), stroke); +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -94,6 +111,32 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option { + let offsets = constraint_glyph_offsets(glyphs); + glyphs + .iter() + .zip(offsets) + .enumerate() + .rev() + .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| { + let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset); + let within = p.x >= top_left.x + && p.x <= top_left.x + size.width + && p.y >= top_left.y + && p.y <= top_left.y + size.height; + within.then_some(index) + }) +} + /// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 60 px cursor at the default value and the full-viewport result at 100. pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { @@ -723,7 +766,7 @@ pub fn selection_overlay<'a>( crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -806,8 +849,10 @@ struct SelectionCanvas { crosshair_bg: [f32; 4], crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, - /// Constraint glyph anchor, outward screen direction, label, and conflict state. - constraint_glyphs: Vec<(Point, [f32; 2], String, bool)>, + /// Constraint glyph anchor, outward screen direction, label, conflict + /// state, whether the pill itself is the current click-to-select target, + /// and the points to mark while the pill is hovered. + constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, } fn draw_grip_marker( @@ -989,6 +1034,18 @@ impl canvas::Program for SelectionCanvas { } } } + if let Some(pos) = cursor.position_in(bounds) { + let glyphs: Vec<_> = self + .constraint_glyphs + .iter() + .map(|(anchor, outward, label, conflict, _, _)| { + (*anchor, *outward, label.clone(), *conflict) + }) + .collect(); + if constraint_glyph_hit_test(&glyphs, pos).is_some() { + return mouse::Interaction::Pointer; + } + } // The resize cursor over a divider is supplied by the input pane_grid // layered above this overlay; `draw` only suppresses the CAD crosshair // there (see `divider_under`). @@ -1703,9 +1760,22 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Constraint glyphs are visual-only and have no hit testing. + // Hit testing shares this layout math with the scene projection. if !self.constraint_glyphs.is_empty() { - let offsets = constraint_glyph_offsets(&self.constraint_glyphs); + // `constraint_glyph_offsets` only needs the anchor/outward/label/ + // conflict quadruple it was written against; project away the + // trailing display fields rather than widen its signature. + let glyphs_for_offsets: Vec<(Point, [f32; 2], String, bool)> = self + .constraint_glyphs + .iter() + .map(|(anchor, outward, label, is_conflicting, _, _)| { + (*anchor, *outward, label.clone(), *is_conflicting) + }) + .collect(); + let offsets = constraint_glyph_offsets(&glyphs_for_offsets); + let hovered = cursor + .position_in(bounds) + .and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); let normal_bg = theme.palette().primary.base.color; let normal_fg = theme.palette().primary.base.text; // A redundant or conflicting constraint gets the danger palette @@ -1714,7 +1784,8 @@ impl canvas::Program for SelectionCanvas { // would show, surfaced right on the geometry. let conflict_bg = theme.palette().danger.base.color; let conflict_fg = theme.palette().danger.base.text; - for ((anchor, outward, label, is_conflicting), tangent_offset) in + let selected_ring = theme.palette().primary.strong.color; + for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in self.constraint_glyphs.iter().zip(offsets) { if !anchor.x.is_finite() || !anchor.y.is_finite() { @@ -1729,17 +1800,58 @@ impl canvas::Program for SelectionCanvas { (size.height * 0.5).into(), ); frame.fill(&pill, bg); - frame.fill_text(canvas::Text { - content: label.clone(), - position: Point::new( - top_left.x + CONSTRAINT_GLYPH_PAD_X, - top_left.y + CONSTRAINT_GLYPH_PAD_Y, - ), - color: fg, - size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); + if *is_selected { + frame.stroke( + &pill, + canvas::Stroke::default().with_color(selected_ring).with_width(2.0), + ); + } + let glyph_center = Point::new( + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, + ); + if label == "T" { + draw_tangent_constraint_glyph(&mut frame, glyph_center, fg); + } else { + frame.fill_text(canvas::Text { + content: label.clone(), + position: glyph_center, + color: fg, + size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } + } + if let Some(index) = hovered { + let red = Color::from_rgb(1.0, 0.0, 0.0); + let stroke = canvas::Stroke::default().with_color(red).with_width(1.5); + for point in &self.constraint_glyphs[index].5 { + let first = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + let second = canvas::Path::line( + Point::new( + point.x - CONSTRAINT_HOVER_MARKER_RADIUS, + point.y + CONSTRAINT_HOVER_MARKER_RADIUS, + ), + Point::new( + point.x + CONSTRAINT_HOVER_MARKER_RADIUS, + point.y - CONSTRAINT_HOVER_MARKER_RADIUS, + ), + ); + frame.stroke(&first, stroke.clone()); + frame.stroke(&second, stroke.clone()); + } } } // Small cross at each acquired tracking point. @@ -3424,6 +3536,9 @@ mod constraint_glyph_tests { assert!( (anchor.y - (left.y + right_size.height) - CONSTRAINT_GLYPH_GAP).abs() < 1e-6 ); + + let click = Point::new(left.x + left_size.width * 0.5, left.y + left_size.height * 0.5); + assert_eq!(constraint_glyph_hit_test(&glyphs, click), Some(0)); } } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 507c150d6..d5b233e06 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -865,13 +865,14 @@ impl PropertiesPanel { self.render_hatch_pattern_row(label, current) } PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), - PropValue::EntityLink { handles, conflicting } => { - render_entity_link_row(label, handles.clone(), *conflicting) + PropValue::EntityLink { id, handles, conflicting } => { + render_entity_link_row(label, *id, handles.clone(), *conflicting) } PropValue::ParamRow { index, name, formula, resolved } => { self.render_param_row(*index, name, formula, resolved) } PropValue::ParamAddRow => render_param_add_row(), + PropValue::ParamsVisibilityToggle(value) => render_params_visibility_toggle_row(*value), } } @@ -1933,8 +1934,13 @@ fn render_ro_with_tooltip_row<'a>( /// field, so this doesn't use the usual `prop_row_widget` label|value split. /// Clicking it selects every entity in `handles`; a conflicting/redundant /// constraint (mirrors the viewport glyph pill's own color cue) tints red. -fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: bool) -> Element<'a, Message> { - let btn = button(text(label).size(FONT_SZ).width(Length::Fill)) +fn render_entity_link_row<'a>( + label: &'a str, + id: crate::scene::parametric_constraints::ConstraintId, + handles: Vec, + conflicting: bool, +) -> Element<'a, Message> { + let link = button(text(label).size(FONT_SZ).width(Length::Fill)) .on_press(Message::PropConstraintLinkClick(handles)) .style(move |theme: &Theme, status| { let palette = theme.palette(); @@ -1955,7 +1961,13 @@ fn render_entity_link_row<'a>(label: &'a str, handles: Vec, conflicting: }) .padding([3, 8]) .width(Length::Fill); - container(btn).width(Length::Fill).into() + let delete = button(text("\u{2715}").size(FONT_SZ)) + .on_press(Message::PropConstraintDelete(id)) + .style(button::text) + .padding([2, 6]); + container(row![link, delete].spacing(2).align_y(iced::Center)) + .width(Length::Fill) + .into() } // ── Parameters section: "+ Add parameter" row ────────────────────────────── @@ -1973,6 +1985,43 @@ fn render_param_add_row<'a>() -> Element<'a, Message> { container(btn).width(Length::Fill).into() } +// ── Parameters section: leading global visibility toggle ─────────────────── + +/// The Parameters section's leading header row (no-selection page): a +/// global on/off toggle for whether any constraint pill in the viewport +/// shows its driven value/parameter-name text — lives next to the +/// named-parameter table it governs. +fn render_params_visibility_toggle_row<'a>(value: bool) -> Element<'a, Message> { + let btn_label = if value { t!("On") } else { t!("Off") }.into_owned(); + let btn = button( + row![ + crate::ui::icons::semantic(crate::ui::icons::layer_visible(value), 13.0), + text(btn_label).size(FONT_SZ), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(Message::ShowConstraintValuesChanged(!value)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak, + _ => palette.background.base, + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { color: palette.background.neutral.color, width: 1.0, radius: 2.0.into() }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([4, 8]) + .width(Length::Fill); + container(row![text(t!("Values").into_owned()).size(FONT_SZ).width(Length::Fill), btn].align_y(iced::Center)) + .width(Length::Fill) + .into() +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) diff --git a/src/ui/ribbon/color_dropdown.rs b/src/ui/ribbon/color_dropdown.rs new file mode 100644 index 000000000..8ec7393b7 --- /dev/null +++ b/src/ui/ribbon/color_dropdown.rs @@ -0,0 +1,333 @@ +//! Object Color Dropdown panel. +//! +//! Provides a 5-section flyout ribbon color control: +//! 1. ByLayer and ByBlock logical color entries +//! 2. 45-swatch Quick Pick Grid (5 rows × 9 columns) +//! 3. Standard Index Colors (ACI 1–9) +//! 4. Recent Colors row (up to 9 swatches) +//! 5. "Select Color..." action to open the full Select Color dialog + +use super::widgets::{muted_text_style, popup_panel_style, popup_row_style}; +use crate::app::Message; +use crate::t; +use crate::ui::properties::acad_color_display; +use acadrust::types::Color as AcadColor; +use iced::widget::{button, column, container, row, text, Space}; +use iced::{Background, Border, Element, Length, Padding, Theme}; + +pub const PANEL_W: f32 = 202.0; +const SWATCH_SIZE: f32 = 18.0; +const SWATCH_GAP: f32 = 2.0; + +/// Curated 45-swatch quick pick grid measured from the AutoCAD ribbon control. +pub const QUICK_PICK_GRID: [[(u8, u8, u8); 9]; 5] = [ + [ + (0x00, 0x00, 0x00), + (0xF8, 0xD7, 0x31), + (0xCD, 0x69, 0x28), + (0x99, 0x1B, 0x1E), + (0x64, 0x21, 0x65), + (0x29, 0x31, 0x89), + (0x22, 0x34, 0x6E), + (0x13, 0x67, 0x34), + (0x72, 0x74, 0x30), + ], + [ + (0x63, 0x64, 0x66), + (0xF1, 0xEB, 0x1F), + (0xF2, 0x67, 0x22), + (0xCD, 0x20, 0x27), + (0x93, 0x27, 0x8F), + (0x45, 0x54, 0xA5), + (0x10, 0x56, 0x89), + (0x13, 0x9B, 0x48), + (0x99, 0x9B, 0x37), + ], + [ + (0x93, 0x95, 0x98), + (0xF4, 0xEE, 0x51), + (0xF8, 0x99, 0x1E), + (0xED, 0x1F, 0x24), + (0xA9, 0x53, 0xA0), + (0x5E, 0x67, 0xAF), + (0x27, 0x76, 0xBB), + (0x58, 0xBA, 0x48), + (0xC8, 0xC9, 0x2D), + ], + [ + (0xC7, 0xC8, 0xCA), + (0xF7, 0xF2, 0x81), + (0xFE, 0xCC, 0x66), + (0xF2, 0x71, 0x72), + (0xD1, 0x9C, 0xC7), + (0x98, 0x98, 0xCC), + (0x7A, 0xAF, 0xDF), + (0xAF, 0xD9, 0xAA), + (0xE5, 0xE8, 0x78), + ], + [ + (0xFF, 0xFF, 0xFF), + (0xF8, 0xF6, 0xB0), + (0xFE, 0xEA, 0xB9), + (0xF7, 0xAB, 0xAE), + (0xDD, 0xB3, 0xD4), + (0xCE, 0xCC, 0xE6), + (0xAD, 0xDD, 0xF7), + (0xD7, 0xEB, 0xD2), + (0xED, 0xEE, 0x99), + ], +]; + +/// The standard ACI 1..=9 index colors pulled directly from the ACI color table. +#[allow(dead_code)] +pub fn aci_1_9() -> [(u8, u8, u8); 9] { + std::array::from_fn(|i| { + acadrust::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128)) + }) +} + +fn swatch_btn<'a>(color: AcadColor, cur_color: AcadColor) -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + let selected = color == cur_color; + + button( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .on_press(Message::RibbonColorChanged(color)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + button::Style { + background: Some(Background::Color(bg)), + border: Border { + color: if selected { + palette.primary.base.color + } else if hovered { + palette.background.base.text + } else { + palette.background.neutral.color + }, + width: if selected { + 2.0 + } else if hovered { + 1.5 + } else { + 1.0 + }, + radius: 1.0.into(), + }, + ..Default::default() + } + }) + .padding(0) + .into() +} + +fn placeholder_swatch<'a>() -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color.scale_alpha(0.5))), + border: Border { + color: palette.background.neutral.color.scale_alpha(0.5), + width: 1.0, + radius: 1.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +fn divider<'a>() -> Element<'a, Message> { + container( + Space::new() + .width(Length::Fill) + .height(Length::Fixed(1.0)), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +fn logical_row<'a>( + color: AcadColor, + cur_color: AcadColor, + label: &'static str, +) -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + let selected = color == cur_color; + + let swatch = container( + Space::new() + .width(Length::Fixed(SWATCH_SIZE)) + .height(Length::Fixed(SWATCH_SIZE)), + ) + .style(move |theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: if selected { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if selected { 2.0 } else { 1.0 }, + radius: 1.0.into(), + }, + ..Default::default() + } + }); + + button( + row![ + swatch, + text(t!(label)).size(11), + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::RibbonColorChanged(color)) + .style(popup_row_style) + .padding([3, 4]) + .width(Length::Fill) + .into() +} + +fn more_colors_row<'a>(active_color: AcadColor) -> Element<'a, Message> { + button( + row![text(t!("Select Color...")).size(11)] + .align_y(iced::Center), + ) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::Ribbon, + active_color, + )) + .style(popup_row_style) + .padding([4, 4]) + .width(Length::Fill) + .into() +} + +/// Builds the complete 5-section color dropdown panel element. +pub fn color_dropdown_panel<'a>( + active_color: AcadColor, + recent_colors: &[AcadColor], +) -> Element<'a, Message> { + // 1. ByLayer and ByBlock + let by_layer = logical_row(AcadColor::ByLayer, active_color, "ByLayer"); + let by_block = logical_row(AcadColor::ByBlock, active_color, "ByBlock"); + let section1 = column![by_layer, by_block].spacing(2); + + // 2. 45-swatch Quick Pick Grid (5 rows × 9 columns) + let mut grid_rows = column![].spacing(SWATCH_GAP); + for row_colors in QUICK_PICK_GRID { + let mut r = row![].spacing(SWATCH_GAP); + for (red, green, blue) in row_colors { + let color = AcadColor::Rgb { + r: red, + g: green, + b: blue, + }; + r = r.push(swatch_btn(color, active_color)); + } + grid_rows = grid_rows.push(r); + } + + // 3. Standard Index Colors (ACI 1..=9) + let index_header = text(t!("Index Color")).size(10).style(muted_text_style); + let mut standard_row = row![].spacing(SWATCH_GAP); + for idx in 1u8..=9 { + let color = AcadColor::Index(idx); + standard_row = standard_row.push(swatch_btn(color, active_color)); + } + let section3 = column![index_header, standard_row].spacing(4); + + // 4. Recent Colors row (up to 9 swatches; pad with placeholders if fewer) + let recent_header = text(t!("Recent Colors")).size(10).style(muted_text_style); + let mut recent_row = row![].spacing(SWATCH_GAP); + let mut count = 0; + for &color in recent_colors.iter().take(9) { + recent_row = recent_row.push(swatch_btn(color, active_color)); + count += 1; + } + while count < 9 { + recent_row = recent_row.push(placeholder_swatch()); + count += 1; + } + let section4 = column![recent_header, recent_row].spacing(4); + + // 5. Select Color... + let section5 = more_colors_row(active_color); + + let content = column![ + section1, + divider(), + grid_rows, + divider(), + section3, + divider(), + section4, + divider(), + section5, + ] + .spacing(6); + + container(content) + .padding(Padding { + top: 6.0, + bottom: 6.0, + left: 12.0, + right: 12.0, + }) + .width(Length::Fixed(PANEL_W)) + .style(popup_panel_style) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quick_pick_grid_dimensions() { + assert_eq!(QUICK_PICK_GRID.len(), 5); + for row in &QUICK_PICK_GRID { + assert_eq!(row.len(), 9); + } + } + + #[test] + fn test_aci_1_9() { + let colors = aci_1_9(); + assert_eq!(colors.len(), 9); + assert_eq!(colors[0], (255, 0, 0)); // Red + assert_eq!(colors[1], (255, 255, 0)); // Yellow + assert_eq!(colors[2], (0, 255, 0)); // Green + assert_eq!(colors[3], (0, 255, 255)); // Cyan + assert_eq!(colors[4], (0, 0, 255)); // Blue + assert_eq!(colors[5], (255, 0, 255)); // Magenta + assert_eq!(colors[6], (255, 255, 255)); // White + assert_eq!(colors[7], (128, 128, 128)); // Dark Gray + assert_eq!(colors[8], (192, 192, 192)); // Light Gray + } + + #[test] + fn test_panel_geometry() { + assert_eq!(PANEL_W, 202.0); + let grid_width = 9.0 * SWATCH_SIZE + 8.0 * SWATCH_GAP; + assert_eq!(grid_width, 178.0); + let padding_h = (PANEL_W - grid_width) / 2.0; + assert_eq!(padding_h, 12.0); + } +} diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index f2e4677c4..54c6ed688 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -73,12 +73,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/region.svg"), options: &[], }, - Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!("../../../assets/icons/wipeout.svg"), - options: &[], - }, Tool { command: "BOUNDARY", label: "Boundary", @@ -107,16 +101,6 @@ const TOOLS: &[Tool] = &[ ("DDPTYPE", "Point Style"), ], }, - Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!("../../../assets/icons/revcloud.svg"), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], - }, ]; #[derive(Clone, Copy)] @@ -305,7 +289,7 @@ mod tests { #[test] fn extension_submenus_resolve_to_their_parent_panel() { - assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); + assert_eq!(parent_panel("MULTIPOINT"), Some(PANEL_ID)); assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); assert_eq!(parent_panel("unknown"), None); } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index a5eb2ffe3..7a71921f0 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -23,6 +23,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; mod draw_panel; mod modify_panel; +mod color_dropdown; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -739,6 +740,7 @@ impl Ribbon { redo_labels: &[String], win: (f32, f32), is_start: bool, + recent_colors: &[AcadColor], ) -> Option> { if is_start { return None; @@ -820,7 +822,7 @@ impl Ribbon { } if open_id == PROP_COLOR_ID { - return self.prop_color_overlay(win); + return self.prop_color_overlay(win, recent_colors); } if open_id == PROP_LINETYPE_ID { return self.prop_linetype_overlay(win); @@ -1169,27 +1171,17 @@ impl Ribbon { ))) } - fn prop_color_overlay(&self, win: (f32, f32)) -> Option> { - let picker = crate::ui::color_select::color_list( - crate::ui::color_select::ColorExtras { - by_layer: true, - by_block: true, - ..Default::default() - }, - Message::RibbonColorChanged, - Message::OpenColorWindow( - crate::app::ColorPickTarget::Ribbon, - self.active_color, - ), - ); + fn prop_color_overlay( + &self, + win: (f32, f32), + recent_colors: &[AcadColor], + ) -> Option> { + let panel = color_dropdown::color_dropdown_panel(self.active_color, recent_colors); - let panel = container(picker) - .style(popup_panel_style) - .width(Length::Fixed(200.0)); - - let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0); + let (align_right, h_pad, top) = + self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( - panel.into(), + panel, align_right, h_pad, top, diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index 835e03c27..3223fd9b0 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -37,6 +37,47 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_align.svg"), options: &[], }, + // ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: + // one-shot bounding-box alignment of the current selection — distinct + // from "ALIGN" above (point-picked 3D placement). No point prompts, so + // each is a plain one-click Tool rather than a dropdown submenu, matching + // this panel's existing flat-list convention. + Tool { + command: "ALIGNLEFT", + label: "Align Left", + icon: include_bytes!("../../../assets/icons/align_left.svg"), + options: &[], + }, + Tool { + command: "ALIGNHCENTER", + label: "Align Horizontal Centers", + icon: include_bytes!("../../../assets/icons/align_hcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNRIGHT", + label: "Align Right", + icon: include_bytes!("../../../assets/icons/align_right.svg"), + options: &[], + }, + Tool { + command: "ALIGNTOP", + label: "Align Top", + icon: include_bytes!("../../../assets/icons/align_top.svg"), + options: &[], + }, + Tool { + command: "ALIGNVCENTER", + label: "Align Vertical Centers", + icon: include_bytes!("../../../assets/icons/align_vcenter.svg"), + options: &[], + }, + Tool { + command: "ALIGNBOTTOM", + label: "Align Bottom", + icon: include_bytes!("../../../assets/icons/align_bottom.svg"), + options: &[], + }, Tool { command: "BREAK", label: "Break", @@ -67,12 +108,6 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), options: &[], }, - Tool { - command: "OVERKILL", - label: "Delete Duplicate Objects", - icon: include_bytes!("../../../assets/icons/modify_overkill.svg"), - options: &[], - }, Tool { command: "DRAWORDER_FRONT", label: "Draw Order", 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/auto_constrain_settings.rs b/src/ui/window/auto_constrain_settings.rs new file mode 100644 index 000000000..2a5afa621 --- /dev/null +++ b/src/ui/window/auto_constrain_settings.rs @@ -0,0 +1,143 @@ +use crate::app::settings::AutoConstrainSettings; +use crate::app::Message; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + settings: &'a AutoConstrainSettings, + selected_row: usize, + distance_input: &'a str, + angle_input: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let heading = row![ + text(crate::t!("Priority")).width(70), + text(crate::t!("Constraint Type")).width(Fill), + text(crate::t!("Apply")).width(60), + ] + .spacing(8); + + let mut list = column![heading].spacing(3); + for (index, kind) in settings.priority.iter().copied().enumerate() { + let enabled = settings.enabled.contains(&kind); + let label = row![ + text(format!("{}", index + 1)).width(70), + text(crate::t!(kind.label())).width(Fill), + checkbox(enabled) + .on_toggle(move |_| Message::AutoConstrainToggleKind(kind)) + .width(60), + ] + .spacing(8) + .align_y(iced::Center); + list = list.push( + button(label) + .on_press(Message::AutoConstrainSelectRow(index)) + .style(if selected_row == index { + button::primary + } else { + button::text + }) + .width(Fill) + .padding([5, 8]), + ); + } + + let reorder = column![ + button(text(crate::t!("Move Up"))) + .on_press_maybe((selected_row > 0).then_some(Message::AutoConstrainMoveUp)) + .width(Fill), + button(text(crate::t!("Move Down"))) + .on_press_maybe( + (selected_row + 1 < settings.priority.len()) + .then_some(Message::AutoConstrainMoveDown) + ) + .width(Fill), + Space::new().height(8), + button(text(crate::t!("Select All"))) + .on_press(Message::AutoConstrainSelectAll) + .width(Fill), + button(text(crate::t!("Clear All"))) + .on_press(Message::AutoConstrainClearAll) + .width(Fill), + button(text(crate::t!("Reset"))) + .on_press(Message::AutoConstrainReset) + .width(Fill), + ] + .spacing(6) + .width(125); + + let types = row![ + container(scrollable(list).height(260)) + .padding(6) + .width(Fill), + reorder + ] + .spacing(12); + + let conditions = column![ + checkbox(settings.tangent_must_share_point) + .label(crate::t!("Tangent objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainToggleTangentPoint), + checkbox(settings.perpendicular_must_intersect) + .label(crate::t!("Perpendicular objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainTogglePerpendicularIntersection), + ] + .spacing(8); + + let tolerances = column![ + text(crate::t!("Tolerances")).size(14), + row![ + text(crate::t!("Distance")).width(130), + text_input("0.05", distance_input) + .on_input(Message::AutoConstrainDistanceChanged) + .width(150), + ] + .align_y(iced::Center), + row![ + text(crate::t!("Angle")).width(130), + text_input("1", angle_input) + .on_input(Message::AutoConstrainAngleChanged) + .width(150), + text("°"), + ] + .align_y(iced::Center), + ] + .spacing(7); + + let actions = row![ + Space::new().width(Fill), + button(text(crate::t!("OK"))) + .on_press(Message::AutoConstrainOk) + .style(button::primary) + .padding([6, 18]), + button(text(crate::t!("Apply"))) + .on_press(Message::AutoConstrainApply) + .style(button::secondary) + .padding([6, 18]), + button(text(crate::t!("Cancel"))) + .on_press(Message::AutoConstrainCancel) + .style(button::secondary) + .padding([6, 18]), + ] + .spacing(8) + .align_y(iced::Center); + + container( + column![ + text(crate::t!("Auto Constrain")).size(16), + types, + conditions, + tolerances, + Space::new().height(Fill), + actions, + ] + .spacing(14) + .width(sizing.width) + .height(sizing.height), + ) + .padding([14, 16]) + .width(sizing.width) + .height(sizing.height) + .style(container::rounded_box) + .into() +} diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 2ea71b51c..c0541d274 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -111,19 +111,44 @@ pub fn view_window<'a>( }; // ── Tab Bar ────────────────────────────────────────────────────────── + // Real tab strip: a single non-wrapping row of tab-shaped buttons with a + // divider underneath, matching the style-manager editor shell. The row + // never wraps — it clips on overflow so "Selection Cycling" can't spill + // onto a second row. let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| { let is_active = state.active_tab == tab; - button(text(label).size(11.5)) + button(text(label).size(11)) .on_press(Message::DraftingSettingsTabChanged(tab)) - .style(if is_active { - button::primary - } else { - button::secondary + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (is_active, status) { + (true, _) => palette.primary.strong, + ( + false, + button::Status::Hovered | button::Status::Pressed, + ) => palette.background.strong, + _ => 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: iced::border::Radius { + top_left: 4.0, + top_right: 4.0, + bottom_right: 0.0, + bottom_left: 0.0, + }, + }, + ..Default::default() + } }) - .padding([5, 9]) + .padding([4, 8]) }; - let tab_bar = row![ + let tab_row = row![ tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")), tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")), tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")), @@ -132,8 +157,21 @@ pub fn view_window<'a>( tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")), tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")), ] - .spacing(4) - .wrap(); + .spacing(2) + .width(Fill) + .clip(true); + + let tab_divider = container(Space::new().width(Fill).height(1)) + .width(Fill) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }); + + let tab_bar = column![tab_row, tab_divider].spacing(0).width(Fill); // ── Tab 1: Snap and Grid ───────────────────────────────────────────── let snap_and_grid_view = { diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 32b0f5d3a..09b3b2314 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -5,6 +5,7 @@ pub mod layer_state_manager; pub mod drawing_units; pub mod geometric_tolerance; pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; pub mod plot; pub mod print_all; 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/fixtures/embedded-image-print.dxf b/tests/fixtures/embedded-image-print.dxf new file mode 100644 index 000000000..6fa5972f6 --- /dev/null +++ b/tests/fixtures/embedded-image-print.dxf @@ -0,0 +1,7522 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +Synthetic reproduction + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +OpenSans + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.175 + 9 +$DIMEXO + 40 +0.125 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.375 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.25 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +12 + 9 +$DIMBLK + 1 +ARCHTICK + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +100.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +EZDXF + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +0.5 + 9 +$DIMGAP + 40 +0.1 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +1 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +OpenSansCondensed-Light + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +2 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +3 + 9 +$DIMDSEP + 70 +46 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +2 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461298.5564930555 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461298.5564930555 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +99 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +0 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +11.0 + 20 +8.5 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +0 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +1 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{AE9B3164-57C5-40A6-B897-3AA314A90313} + 9 +$VERSIONGUID + 2 +{DBBE3EFA-6A7C-4482-B175-433C33A11394} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +8B +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +12.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +26 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +41 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Light.ttf + 4 + + 0 +STYLE + 5 +42 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-LightItalic.ttf + 4 + + 0 +STYLE + 5 +43 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Regular.ttf + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Italic.ttf + 4 + + 0 +STYLE + 5 +45 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBold.ttf + 4 + + 0 +STYLE + 5 +46 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBoldItalic.ttf + 4 + + 0 +STYLE + 5 +47 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Bold.ttf + 4 + + 0 +STYLE + 5 +48 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +49 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBold.ttf + 4 + + 0 +STYLE + 5 +4A +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBoldItalic.ttf + 4 + + 0 +STYLE + 5 +4B +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Bold.ttf + 4 + + 0 +STYLE + 5 +4C +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Light.ttf + 4 + + 0 +STYLE + 5 +4D +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-LightItalic.ttf + 4 + + 0 +STYLE + 5 +4E +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Regular.ttf + 4 + + 0 +STYLE + 5 +4F +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Bold.ttf + 4 + + 0 +STYLE + 5 +50 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +51 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Italic.ttf + 4 + + 0 +STYLE + 5 +52 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Regular.ttf + 4 + + 0 +STYLE + 5 +53 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Bold.ttf + 4 + + 0 +STYLE + 5 +54 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +55 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Italic.ttf + 4 + + 0 +STYLE + 5 +56 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Regular.ttf + 4 + + 0 +STYLE + 5 +57 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Bold.ttf + 4 + + 0 +STYLE + 5 +58 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +59 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Italic.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +96 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +97 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +12 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +73 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZDXF + 70 +0 + 40 +1.0 + 41 +0.175 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +74 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7C +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_100_H25_CM + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7D +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_50_H25_CM + 70 +0 + 40 +1.0 + 41 +0.125 + 42 +0.0625 + 43 +3.75 + 44 +0.1875 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.125 +141 +2.5 +142 +0.0625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7E +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_25_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0625 + 42 +0.03125 + 43 +3.75 + 44 +0.09375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0625 +141 +2.5 +142 +0.03125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.025 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7F +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_20_H25_CM + 70 +0 + 40 +1.0 + 41 +0.05 + 42 +0.025 + 43 +3.75 + 44 +0.07500000000000001 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.05 +141 +2.5 +142 +0.025 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.020000000000000004 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +80 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_10_H25_CM + 70 +0 + 40 +1.0 + 41 +0.025 + 42 +0.0125 + 43 +3.75 + 44 +0.037500000000000006 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.025 +141 +2.5 +142 +0.0125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.010000000000000002 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +81 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_5_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0125 + 42 +0.00625 + 43 +3.75 + 44 +0.018750000000000003 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0125 +141 +2.5 +142 +0.00625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.005000000000000001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +82 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_1_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0025 + 42 +0.00125 + 43 +3.75 + 44 +0.00375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0025 +141 +2.5 +142 +0.00125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +83 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +1 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +88 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS_INSIDE + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +0 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +1 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +0 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +89 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_CURVED + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +2 + 90 +2 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +6 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +74 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_ARCHTICK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +78 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +84 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDBLANK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SyntheticImage +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +75 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_ARCHTICK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_ARCHTICK + 1 + + 0 +LWPOLYLINE + 5 +77 +330 +74 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +2 + 70 +0 + 43 +0.15 + 10 +-0.5 + 20 +-0.5 + 10 +0.5 + 20 +0.5 + 0 +ENDBLK + 5 +76 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +79 +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +7B +330 +78 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +7A +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +85 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDBLANK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDBLANK + 1 + + 0 +LWPOLYLINE + 5 +87 +330 +84 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-1.0 + 20 +0.16439898372402537 + 10 +0.0 + 20 +0.0 + 10 +-1.0 + 20 +-0.16439898372402537 + 0 +ENDBLK + 5 +86 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8D +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SyntheticImage + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SyntheticImage + 1 + + 0 +OLE2FRAME + 5 +8F +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbOle2Frame + 70 +2 + 3 +Picture + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +2.0 + 21 +0.0 + 31 +0.0 + 71 +3 + 72 +0 + 73 +0 + 90 +8192 +310 +D0CF11E0A1B11AE1000000000000000000000000000000003E000300FEFF090006000000000000000000000001000000000000000000000000100000FEFFFFFF00000000FEFFFFFF0000000001000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFF52006F006F007400200045006E00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000501FFFFFFFFFFFFFFFF01000000000000000000000000000000000000000000000000000000000000000000000000000000FEFFFFFF000000 +310 +000000000043004F004E00540045004E005400530000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000201FFFFFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000020000003618 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +0000000000000000FEFFFFFFFDFFFFFF030000000400000005000000060000000700000008000000090000000A0000000B0000000C0000000D0000000E000000FEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFF424D361800000000000036000000280000004000000020000000010018000000000000180000C40E0000C40E000000000000000000000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF00FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF00 +310 +00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF000000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF00FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF00FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + 1 +OLE + 0 +ENDBLK + 5 +8E +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +VIEWPORT + 5 +8A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +5.5 + 20 +4.25 + 30 +0.0 + 40 +12.100000000000001 + 41 +9.350000000000001 + 68 +1 + 69 +1 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +9.350000000000001 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +INSERT + 5 +90 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbBlockReference + 2 +SyntheticImage + 10 +2.0 + 20 +4.0 + 30 +0.0 + 0 +LWPOLYLINE + 5 +92 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2.0 + 20 +4.0 + 10 +4.0 + 20 +4.0 + 10 +4.0 + 20 +5.0 + 10 +2.0 + 20 +5.0 + 0 +TEXT + 5 +93 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +2.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +EMBEDDED BITMAP +100 +AcDbText + 0 +LWPOLYLINE + 5 +94 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +6.0 + 20 +4.0 + 10 +8.0 + 20 +4.0 + 10 +7.0 + 20 +5.0 + 0 +TEXT + 5 +95 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +6.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +VECTOR CONTROL +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 3 +2dWireframe +350 +5A + 3 +Basic +350 +5B + 3 +Brighten +350 +5C + 3 +ColorChange +350 +5D + 3 +Conceptual +350 +5E + 3 +Dim +350 +5F + 3 +EdgeColorOff +350 +60 + 3 +Facepattern +350 +61 + 3 +Flat +350 +62 + 3 +FlatWithEdges +350 +63 + 3 +Gouraud +350 +64 + 3 +GouraudWithEdges +350 +65 + 3 +Hidden +350 +66 + 3 +JitterOff +350 +67 + 3 +Linepattern +350 +68 + 3 +Modeling +350 +69 + 3 +OverhangOff +350 +6A + 3 +Realistic +350 +6B + 3 +Shaded +350 +6C + 3 +Shaded with edges +350 +6D + 3 +Shades of Gray +350 +6E + 3 +Sketchy +350 +6F + 3 +Thicken +350 +70 + 3 +Wireframe +350 +71 + 3 +X-Ray +350 +72 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +Example saved printer + 4 +ezdxf_(11.00_x_8.50_Inches) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +279.4 + 45 +215.89999999999998 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +0 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +0.03937007874015748 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +11.0 + 21 +8.5 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B +331 +8A + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +98 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.360639+00:00 + 0 +VISUALSTYLE + 5 +5A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +2dWireframe + 70 +4 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Basic + 70 +7 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Brighten + 70 +12 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +ColorChange + 70 +16 + 90 +2 + 40 +0.6 + 62 +8 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Conceptual + 70 +9 + 90 +3 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Dim + 70 +11 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +60 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +EdgeColorOff + 70 +22 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +61 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Facepattern + 70 +15 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +62 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Flat + 70 +0 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +63 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +FlatWithEdges + 70 +1 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +64 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Gouraud + 70 +2 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +65 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +GouraudWithEdges + 70 +3 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +66 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Hidden + 70 +6 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +67 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +JitterOff + 70 +20 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +68 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Linepattern + 70 +14 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +69 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Modeling + 70 +10 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +OverhangOff + 70 +21 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +6B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Realistic + 70 +8 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded + 70 +27 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded with edges + 70 +26 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shades of Gray + 70 +23 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Sketchy + 70 +24 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +70 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Thicken + 70 +13 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +71 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Wireframe + 70 +5 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +72 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +X-Ray + 70 +25 + 90 +2 + 40 +0.5 + 62 +7 +290 +0 +291 +0 + 0 +DICTIONARYVAR + 5 +98 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.373779+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/tests/fixtures/embedded-image-print.md b/tests/fixtures/embedded-image-print.md new file mode 100644 index 000000000..8114d574d --- /dev/null +++ b/tests/fixtures/embedded-image-print.md @@ -0,0 +1,8 @@ +Synthetic regression fixture for #1266. Generated with ezdxf/Pillow from basic +geometry; it contains no external references or customer drawing content. + +Layout1 has an INSERT of SyntheticImage at (2, 4). That block contains one +OLE2FRAME whose compound-file CONTENTS stream embeds a 64 x 32 BMP: red on +the left, blue on the right, with a green diagonal from top-left to bottom-right. +The frame occupies (0, 0) to (2, 1) in the block. Text and a triangle provide +vector controls for manually opening the fixture and checking Plot Preview. diff --git a/tests/sketch_constraints_solve.rs b/tests/parametric_constraints_solve.rs similarity index 71% rename from tests/sketch_constraints_solve.rs rename to tests/parametric_constraints_solve.rs index 6741759c3..eb82e2877 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, @@ -127,6 +122,77 @@ fn horizontal_constraint_levels_the_line_when_an_endpoint_moves() { ); } +#[test] +fn horizontal_constraint_levels_a_polyline_segment() { + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(5.0, 2.0), + acadrust::types::Vector2::new(10.0, 7.0), + ]), + )); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::segment(polyline, 1)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert!( + (polyline.vertices[1].location.y - polyline.vertices[2].location.y).abs() < 1e-6, + "the selected segment should be horizontal" + ); +} + +#[test] +fn horizontal_constraint_does_not_flatten_a_polyline_arc_segment() { + let mut source = acadrust::entities::LwPolyline::new(); + source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 0.5); + source.add_point(acadrust::types::Vector2::new(5.0, 2.0)); + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(source)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::segment(polyline, 0)], + None, + ); + + scene.bump_entities(&[(polyline, ChangeKind::Modified)]); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + assert_eq!(polyline.vertices[0].location.y, 0.0); + assert_eq!(polyline.vertices[1].location.y, 2.0); + assert_eq!(polyline.vertices[0].bulge, 0.5); +} + +#[test] +fn horizontal_constraint_aligns_two_selected_points() { + let mut scene = Scene::new(); + 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 + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Horizontal, + vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], + None, + ); + + scene.bump_entities(&[(b, ChangeKind::Modified)]); + let (a_start, _) = line_endpoints(&scene, a); + let (b_start, _) = line_endpoints(&scene, b); + assert!((a_start.y - b_start.y).abs() < 1e-6); +} + #[test] fn parallel_constraint_rotates_the_other_line_when_one_moves() { let mut scene = Scene::new(); @@ -134,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, ); @@ -163,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)), ); @@ -194,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())), ); @@ -236,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())), ); @@ -260,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)]); @@ -284,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)]); @@ -309,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, ); @@ -335,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, ); @@ -363,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(), @@ -404,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(); @@ -425,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(); @@ -463,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(), @@ -499,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]; @@ -548,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, ); @@ -578,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, ); @@ -603,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, ); @@ -636,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, ); @@ -655,23 +733,75 @@ fn midpoint_constraint_pulls_a_point_onto_a_lines_midpoint() { ); } +#[test] +fn midpoint_constraint_supports_a_point_entity() { + let mut scene = Scene::new(); + let base = add_line(&mut scene, 0.0, 0.0, 10.0, 6.0); + let point = scene.add_entity(EntityType::Point(acadrust::entities::Point::at( + Vector3::new(20.0, 20.0, 0.0), + ))); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Midpoint, + vec![ParametricRef::point(point, 0), ParametricRef::whole(base)], + None, + ); + + scene.bump_entities(&[(base, ChangeKind::Modified)]); + let (start, end) = line_endpoints(&scene, base); + let EntityType::Point(point) = scene.document.get_entity(point).unwrap() else { + panic!("expected a point"); + }; + assert!((point.location.x - (start.x + end.x) / 2.0).abs() < 1e-6); + assert!((point.location.y - (start.y + end.y) / 2.0).abs() < 1e-6); +} + +#[test] +fn coincident_constraint_supports_a_block_insertion_point() { + let mut scene = Scene::new(); + let line = add_line(&mut scene, 0.0, 0.0, 10.0, 4.0); + let insert = scene.add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "fixture", + Vector3::new(30.0, 20.0, 0.0), + ))); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Coincident, + vec![ + ParametricRef::point(line, 1), + ParametricRef::point(insert, 0), + ], + None, + ); + + scene.bump_entities(&[(line, ChangeKind::Modified)]); + let (_, end) = line_endpoints(&scene, line); + let EntityType::Insert(insert) = scene.document.get_entity(insert).unwrap() else { + panic!("expected a block reference"); + }; + assert!((insert.insert_point.x - end.x).abs() < 1e-6); + assert!((insert.insert_point.y - end.y).abs() < 1e-6); +} + #[test] fn fixed_constraint_holds_an_entity_in_place_despite_a_connected_edit() { let mut scene = Scene::new(); 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, ); @@ -705,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, ); @@ -723,6 +856,95 @@ fn point_on_curve_constraint_pulls_a_point_onto_a_circles_circumference() { ); } +#[test] +fn point_on_curve_constraint_pulls_a_point_onto_an_ellipse() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let marker = add_line(&mut scene, 12.0, 8.0, 13.0, 8.0); + + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![ + ParametricRef::point(marker, 0), + ParametricRef::whole(ellipse), + ], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (point, _) = line_endpoints(&scene, marker); + let a = major.length(); + let b = a * ratio; + let u = major / a; + let v = Vector3::new(-u.y, u.x, 0.0); + let offset = point - center; + let equation = (offset.dot(&u) / a).powi(2) + (offset.dot(&v) / b).powi(2); + assert!( + (equation - 1.0).abs() < 1e-5, + "point should satisfy the solved ellipse equation: {equation}" + ); +} + +#[test] +fn tangent_constraint_solves_an_ellipse_and_line() { + let mut scene = Scene::new(); + let ellipse = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let line = add_line(&mut scene, -8.0, 8.0, 8.0, 8.0); + + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Tangent, + vec![ParametricRef::whole(ellipse), ParametricRef::whole(line)], + None, + ); + scene.bump_entities(&[(line, ChangeKind::Modified)]); + + let (center, major, ratio) = ellipse_geom(&scene, ellipse); + let (start, end) = line_endpoints(&scene, line); + let direction = (end - start).normalize(); + let normal = Vector3::new(-direction.y, direction.x, 0.0); + let unit_major = major.normalize(); + let unit_minor = Vector3::new(-unit_major.y, unit_major.x, 0.0); + let a = major.length(); + let b = a * ratio; + let support = + ((a * normal.dot(&unit_major)).powi(2) + (b * normal.dot(&unit_minor)).powi(2)).sqrt(); + let distance = (center - start).dot(&normal).abs(); + assert!( + (distance - support).abs() < 1e-5, + "line should be tangent to the solved ellipse: distance={distance} support={support}" + ); +} + +#[test] +fn equal_constraint_matches_ellipse_major_axes() { + let mut scene = Scene::new(); + let a = add_ellipse(&mut scene, 0.0, 0.0, (5.0, 0.0), 0.6); + let b = add_ellipse(&mut scene, 20.0, 0.0, (2.0, 0.0), 0.5); + + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Equal, + vec![ParametricRef::whole(a), ParametricRef::whole(b)], + None, + ); + scene.bump_entities(&[(b, ChangeKind::Modified)]); + + let (_, major_a, _) = ellipse_geom(&scene, a); + let (_, major_b, _) = ellipse_geom(&scene, b); + assert!( + (major_a.length() - major_b.length()).abs() < 1e-5, + "ellipse major radii should match: {} vs {}", + major_a.length(), + major_b.length() + ); +} + #[test] fn equal_distance_constraint_matches_a_second_point_pairs_separation() { let mut scene = Scene::new(); @@ -732,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, ); @@ -766,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, ); @@ -813,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)]); @@ -835,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)]); @@ -858,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)]); @@ -896,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, ); @@ -932,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)]); @@ -964,26 +1197,87 @@ fn an_arcs_endpoint_stays_consistent_with_its_center_radius_and_angle_after_solv } #[test] -fn point_on_curve_constraint_pulls_a_point_onto_an_arcs_underlying_circle() { +fn point_on_curve_constraint_keeps_a_point_within_an_arcs_sweep() { let mut scene = Scene::new(); let arc = add_arc(&mut scene, 0.0, 0.0, 5.0, 0.0, std::f64::consts::PI); - let marker = add_line(&mut scene, 20.0, 20.0, 21.0, 20.0); + let marker = add_line(&mut scene, 0.0, -20.0, 1.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)]); - let (center, radius) = arc_geom(&scene, arc); + let (center, radius, start_angle, end_angle) = + match scene.document.get_entity(arc).expect("entity exists") { + EntityType::Arc(arc) => (arc.center, arc.radius, arc.start_angle, arc.end_angle), + other => panic!("expected an Arc, got {other:?}"), + }; let (p, _) = line_endpoints(&scene, marker); let dist = ((p.x - center.x).powi(2) + (p.y - center.y).powi(2)).sqrt(); assert!( (dist - radius).abs() < 1e-6, - "point should land exactly on the arc's circle: dist={dist} radius={radius}" + "point should land exactly on the arc: dist={dist} radius={radius}" + ); + assert!( + cadkernel::geom2d::angle_within_arc( + (p.y - center.y).atan2(p.x - center.x), + start_angle, + end_angle, + ), + "point must stay within the solved arc sweep: {p:?}" + ); +} + +#[test] +fn point_on_curve_constraint_keeps_a_point_within_a_polyline_arc_segment() { + let mut source = acadrust::entities::LwPolyline::new(); + source.add_point_with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 1.0); + source.add_point(acadrust::types::Vector2::new(10.0, 0.0)); + let mut scene = Scene::new(); + let polyline = scene.add_entity(EntityType::LwPolyline(source)); + let marker = add_line(&mut scene, 5.0, 20.0, 6.0, 20.0); + + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::PointOnCurve, + vec![ + ParametricRef::point(marker, 0), + ParametricRef::segment(polyline, 0), + ], + None, + ); + scene.bump_entities(&[(marker, ChangeKind::Modified)]); + + let (point, _) = line_endpoints(&scene, marker); + let EntityType::LwPolyline(polyline) = scene.document.get_entity(polyline).unwrap() else { + panic!("expected a lightweight polyline"); + }; + let arc = cadkernel::geom2d::BulgeArc::from_bulge( + [ + polyline.vertices[0].location.x, + polyline.vertices[0].location.y, + ], + [ + polyline.vertices[1].location.x, + polyline.vertices[1].location.y, + ], + polyline.vertices[0].bulge, + ) + .unwrap(); + let distance = (point.x - arc.center[0]).hypot(point.y - arc.center[1]); + assert!((distance - arc.radius).abs() < 1e-6); + assert!( + cadkernel::geom2d::angle_within_arc( + (point.y - arc.center[1]).atan2(point.x - arc.center[0]), + arc.start_angle, + arc.start_angle + arc.sweep, + ), + "point must stay within the solved polyline arc segment: {point:?}" ); } @@ -996,16 +1290,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)]); @@ -1032,10 +1333,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)]); @@ -1055,10 +1356,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)]); @@ -1083,10 +1384,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)]); @@ -1102,42 +1403,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 @@ -1152,10 +1417,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(&[ @@ -1178,10 +1446,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, ); @@ -1215,10 +1486,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)]); @@ -1257,10 +1531,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(&[ @@ -1294,11 +1571,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, ); @@ -1328,10 +1612,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/pdf_export_images_check.rs b/tests/pdf_export_images_check.rs new file mode 100644 index 000000000..c625faa36 --- /dev/null +++ b/tests/pdf_export_images_check.rs @@ -0,0 +1,512 @@ +//! #1266: decoded IMAGE/OLE content must survive the shared PDF/preview/print path. +#![cfg(not(target_arch = "wasm32"))] + +use acadrust::entities::{Insert, Viewport, Wipeout}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use std::sync::Arc; +use OpenCADStudio::io::pdf_export::{ + export_pdf, export_pdf_pages, PdfPageInput, PdfPlotOptions, PlotContent, PlotGroupSplits, + PlotImage, PlotWire, +}; +use OpenCADStudio::scene::model::image_model::{ImageModel, ImageQuadVertex}; +use OpenCADStudio::scene::{Scene, WireModel}; + +fn embedded_scene() -> Scene { + let mut scene = Scene::new(); + scene.document = OpenCADStudio::io::load_bytes( + "embedded-image-print.dxf", + include_bytes!("fixtures/embedded-image-print.dxf").to_vec(), + ) + .expect("synthetic embedded bitmap DXF"); + scene.images = + OpenCADStudio::scene::build_derived_caches_with_progress(&scene.document, &|_| {}, None) + .images; + scene.set_current_layout("Layout1".into()); + scene +} + +fn page(images: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { + images, + ..Default::default() + }, + paper_w: 100.0, + paper_h: 100.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } +} + +fn export(pages: &[PdfPageInput]) -> Vec { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ocs1266-{}-{}.pdf", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if let [page] = pages { + export_pdf(page, &path).expect("export bitmap PDF"); + } else { + export_pdf_pages(pages, &path, None).expect("export bitmap PDF pages"); + } + let bytes = std::fs::read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + bytes +} + +struct Raster { + pixels: Vec, + width: usize, + height: usize, + scale: f32, +} +impl Raster { + fn from_pdf(bytes: &[u8], page: usize) -> Self { + Self::at_scale(bytes, page, 2.0) + } + + fn at_scale(bytes: &[u8], page: usize, scale: f32) -> Self { + let pdf = hayro::hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let pixmap = hayro::render( + &pdf.pages()[page], + &hayro::RenderCache::new(), + &hayro::hayro_interpret::InterpreterSettings::default(), + &hayro::RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: hayro::vello_cpu::color::palette::css::WHITE, + ..Default::default() + }, + ); + Self { + pixels: pixmap.data_as_u8_slice().to_vec(), + width: u32::from(pixmap.width()) as usize, + height: u32::from(pixmap.height()) as usize, + scale, + } + } + + fn assert_color(&self, x_mm: f32, y_mm: f32, expected: [u8; 3]) { + let x = (x_mm * 72.0 / 25.4 * self.scale) as usize; + let y = self.height - 1 - (y_mm * 72.0 / 25.4 * self.scale) as usize; + let actual = &self.pixels[(y * self.width + x) * 4..][..3]; + assert!( + actual + .iter() + .zip(expected) + .all(|(&a, b)| a.abs_diff(b) < 25), + "at ({x_mm}, {y_mm}): expected {expected:?}, got {actual:?}" + ); + } +} + +fn image() -> PlotImage { + let corners = [ + [10.0, 10.0, 0.0], + [50.0, 10.0, 0.0], + [50.0, 50.0, 0.0], + [10.0, 50.0, 0.0], + ]; + let uv = [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; + // Four distinct quadrants detect flips, reflections, and diagonal seams. + let pixels = (0..32) + .flat_map(|y| { + (0..32).flat_map(move |x| match (x < 16, y < 16) { + (true, true) => [255, 0, 0, 255], + (false, true) => [0, 0, 255, 255], + (true, false) => [0, 255, 0, 255], + (false, false) => [255, 255, 0, 255], + }) + }) + .collect(); + PlotImage { + image: ImageModel { + render_instance: None, + file_path: "synthetic".into(), + pixels: Arc::new(pixels), + width: 32, + height: 32, + opacity: 1.0, + corners, + corners_low: [[0.0; 3]; 4], + draw_depth: 0.5, + verts: [0, 1, 2, 0, 2, 3] + .into_iter() + .map(|i| ImageQuadVertex { + pos: corners[i], + pos_low: [0.0; 3], + uv: uv[i], + }) + .collect(), + }, + clips: Vec::new(), + } +} + +#[test] +fn embedded_ole_inside_insert_reaches_rendered_pdf() { + let scene = embedded_scene(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert_eq!((images[0].image.width, images[0].image.height), (64, 32)); + assert!(scene + .images + .values() + .any(|source| Arc::ptr_eq(&source.pixels, &images[0].image.pixels))); + let mut input = page(images); + input.scale = 10.0; + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(23.0, 42.0, [255, 0, 0]); + raster.assert_color(37.0, 48.0, [0, 0, 255]); + raster.assert_color(25.0, 47.5, [0, 255, 0]); + raster.assert_color(45.0, 45.0, [255, 255, 255]); +} + +#[test] +fn rotated_nonuniform_insert_preserves_pixel_orientation() { + let mut scene = embedded_scene(); + let handle = scene + .document + .entities() + .find_map(|e| match e { + EntityType::Insert(i) if i.block_name == "SyntheticImage" => Some(i.common.handle), + _ => None, + }) + .unwrap(); + let Some(EntityType::Insert(insert)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + insert.insert_point = Vector3::new(60.0, 20.0, 0.0); + insert.rotation = std::f64::consts::FRAC_PI_2; + insert.set_x_scale(20.0); + insert.set_y_scale(10.0); + let raster = Raster::from_pdf(&export(&[page(scene.paper_plot_images())]), 0); + raster.assert_color(58.0, 26.0, [255, 0, 0]); + raster.assert_color(52.0, 54.0, [0, 0, 255]); + raster.assert_color(52.5, 30.0, [0, 255, 0]); + raster.assert_color(65.0, 40.0, [255, 255, 255]); +} + +#[test] +fn clipping_page_transform_and_shared_resources_survive_serialization() { + let mut first = image(); + // A visible-region triangle (IMAGE clip), plus inherited and viewport clips. + first.image.verts.truncate(3); + first + .clips + .push(vec![[15.0, 5.0], [55.0, 5.0], [55.0, 45.0], [15.0, 45.0]]); + first + .clips + .push(vec![[5.0, 15.0], [45.0, 15.0], [45.0, 55.0], [5.0, 55.0]]); + let mut second = page(vec![first.clone()]); + second.rotation_deg = 90; + second.scale = 0.5; + second.offset_x = 10.0; + second.offset_y = 10.0; + let bytes = export(&[page(vec![first]), second]); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|p| p.ops.iter()) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!( + uses.len(), + 2, + "one image draw per affine quad, without a diagonal seam" + ); + assert_eq!(uses[0], uses[1], "pages should share one image resource"); + let raster = Raster::from_pdf(&bytes, 0); + raster.assert_color(40.0, 20.0, [255, 255, 0]); + for (x, y) in [(12.0, 20.0), (48.0, 20.0), (20.0, 40.0), (40.0, 12.0)] { + raster.assert_color(x, y, [255, 255, 255]); + } + Raster::from_pdf(&bytes, 1).assert_color(85.0, 25.0, [255, 255, 0]); +} + +#[test] +fn image_alpha_and_plot_transparency_are_preserved() { + let mut plot = image(); + plot.image.opacity = 0.5; + // Intrinsic transparent texels remain transparent with either plot setting. + let pixels = Arc::make_mut(&mut plot.image.pixels); + for y in 16..32 { + for x in 0..16 { + pixels[(y * 32 + x) * 4 + 3] = 0; + } + } + let opaque = page(vec![plot.clone()]); + let mut transparent = page(vec![plot]); + transparent.options.transparency = true; + let bytes = export(&[opaque, transparent]); + let off = Raster::from_pdf(&bytes, 0); + off.assert_color(20.0, 40.0, [255, 0, 0]); + off.assert_color(20.0, 20.0, [255, 255, 255]); + let on = Raster::from_pdf(&bytes, 1); + on.assert_color(20.0, 40.0, [255, 128, 128]); + on.assert_color(20.0, 20.0, [255, 255, 255]); +} + +#[test] +fn depth_and_paper_model_groups_order_images_with_wipeouts_and_wires() { + let mut scene = Scene::new(); + let wipeout = Wipeout::polygonal( + &[ + Vector2::new(10.0, 30.0), + Vector2::new(30.0, 30.0), + Vector2::new(30.0, 50.0), + Vector2::new(10.0, 50.0), + ], + 0.0, + ); + scene.add_entity(EntityType::Wipeout(wipeout)); + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + assert_eq!(input.content.wipeouts.len(), 1); + input.content.wipeouts[0].draw_depth = 0.8; + let mut wire = WireModel::solid( + "over image".into(), + vec![[35.0, 15.0, 0.0], [45.0, 15.0, 0.0]], + [0.0, 0.0, 0.0, 1.0], + false, + ); + wire.line_weight_px = 8.0; + input.content.wires = Arc::new(vec![PlotWire { + wire, + draw_depth: 0.9, + }]); + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(20.0, 40.0, [255, 255, 255]); + raster.assert_color(40.0, 40.0, [0, 0, 255]); + raster.assert_color(40.0, 15.0, [0, 0, 0]); + + // First group wins by depth internally, second group still paints last. + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + input.content.wipeouts[0].draw_depth = 0.9; + input.content.group_splits = PlotGroupSplits { + wipeouts: 1, + ..Default::default() + }; + Raster::from_pdf(&export(&[input]), 0).assert_color(20.0, 40.0, [255, 0, 0]); +} + +#[test] +fn viewport_projection_clips_images_and_respects_frozen_and_nonplot_layers() { + let mut scene = embedded_scene(); + scene.set_current_layout("Model".into()); + scene.ensure_layer("Bitmap"); + let mut insert = Insert::new("SyntheticImage", Vector3::new(0.0, 0.0, 0.0)); + insert.common.layer = "Bitmap".into(); + insert.set_x_scale(20.0); + insert.set_y_scale(20.0); + scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Layout1".into()); + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.view_height = 20.0; + viewport.view_center = Vector3::new(20.0, 10.0, 0.0); + viewport.id = 2; + viewport.status.is_on = true; + let handle = scene.add_entity(EntityType::Viewport(viewport)); + let (_, _, _, images) = scene.viewport_plot_fills(); + assert_eq!(images.len(), 1); + let raster = Raster::from_pdf(&export(&[page(images)]), 0); + raster.assert_color(42.0, 43.0, [255, 0, 0]); + raster.assert_color(58.0, 57.0, [0, 0, 255]); + raster.assert_color(35.0, 45.0, [255, 255, 255]); + let layer = scene.document.layers.get("Bitmap").unwrap().handle; + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.push(layer); + assert!(scene.viewport_plot_fills().3.is_empty()); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.clear(); + scene + .document + .layers + .get_mut("Bitmap") + .unwrap() + .is_plottable = false; + assert!(scene.viewport_plot_fills().3.is_empty()); +} + +#[test] +fn linked_image_uses_decoded_pixels_and_its_image_clip_without_reopening() { + use acadrust::entities::{ClipBoundary, RasterImage}; + let path = std::env::temp_dir().join(format!("ocs1266-linked-{}.png", std::process::id())); + let source = image(); + image::save_buffer(&path, &source.image.pixels, 32, 32, image::ColorType::Rgba8).unwrap(); + let mut raster = RasterImage::new( + path.to_str().unwrap(), + Vector3::new(10.0, 10.0, 0.0), + 32.0, + 32.0, + ); + raster.u_vector = Vector3::new(1.25, 0.0, 0.0); + raster.v_vector = Vector3::new(0.0, 1.25, 0.0); + raster.clipping_enabled = true; + raster.clip_boundary = + ClipBoundary::rectangular(Vector2::new(0.0, 0.0), Vector2::new(16.0, 32.0)); + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::RasterImage(raster)); + scene.populate_images_from_document(); + std::fs::remove_file(path).unwrap(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert!(Arc::ptr_eq( + &images[0].image.pixels, + &scene.images[&handle].pixels + )); + let rendered = Raster::from_pdf(&export(&[page(images)]), 0); + rendered.assert_color(20.0, 40.0, [255, 0, 0]); + rendered.assert_color(20.0, 20.0, [0, 255, 0]); + rendered.assert_color(40.0, 40.0, [255, 255, 255]); +} + +#[test] +fn equal_sized_sources_remain_distinct_across_pages() { + let pages = { + let red = image(); + let mut cyan = image(); + Arc::make_mut(&mut cyan.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 255, 255, 255])); + assert!(!Arc::ptr_eq(&red.image.pixels, &cyan.image.pixels)); + vec![ + page(vec![red.clone()]), + page(vec![cyan.clone()]), + page(vec![red]), + page(vec![cyan]), + ] + }; + let bytes = export(&pages); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|page| &page.ops) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!(uses.len(), 4); + assert_ne!(uses[0], uses[1]); + assert_eq!(uses[0], uses[2]); + assert_eq!(uses[1], uses[3]); + for (index, expected) in [[255, 0, 0], [0, 255, 255], [255, 0, 0], [0, 255, 255]] + .into_iter() + .enumerate() + { + Raster::from_pdf(&bytes, index).assert_color(20.0, 40.0, expected); + } +} + +#[test] +fn affine_triangle_clip_has_no_shared_edge_seam() { + let mut plot = image(); + Arc::make_mut(&mut plot.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 0, 0, 128])); + let mut sheared = plot.clone(); + let transform = |[x, y, z]: [f32; 3]| [x + 0.27 * y + 5.0, 0.19 * x + y + 3.0, z]; + sheared + .image + .corners + .iter_mut() + .for_each(|p| *p = transform(*p)); + sheared + .image + .verts + .iter_mut() + .for_each(|v| v.pos = transform(v.pos)); + let pages = [page(vec![plot]), page(vec![sheared])]; + let bytes = export(&pages); + for (index, page) in pages.iter().enumerate() { + let raster = Raster::at_scale(&bytes, index, 8.0); + let corners = page.content.images[0].image.corners; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let determinant = u[0] * v[1] - u[1] * v[0]; + let mut checked = 0; + for y in 0..raster.height { + for x in 0..raster.width { + let dx = (x as f32 + 0.5) / raster.scale * 25.4 / 72.0 - corners[0][0]; + let dy = (raster.height as f32 - y as f32 - 0.5) / raster.scale * 25.4 / 72.0 + - corners[0][1]; + let a = (dx * v[1] - dy * v[0]) / determinant; + let b = (u[0] * dy - u[1] * dx) / determinant; + if (0.15..0.85).contains(&a) && (0.15..0.85).contains(&b) { + let rgb = &raster.pixels[(y * raster.width + x) * 4..][..3]; + assert!(rgb.iter().all(|&c| c.abs_diff(127) <= 2), + "page {index}, pixel ({x}, {y}): shared-edge gap or double coverage: {rgb:?}"); + checked += 1; + } + } + } + assert!(checked > 100_000); + } +} + +#[test] +fn malformed_bitmap_reports_an_error_without_replacing_the_pdf() { + let path = std::env::temp_dir().join(format!("ocs1266-invalid-{}.pdf", std::process::id())); + std::fs::write(&path, b"existing PDF").unwrap(); + let mut pixels = image(); + pixels.image.width += 1; + let mut coordinates = image(); + coordinates.image.corners[0][0] = f32::NAN; + let mut mapping = image(); + mapping.image.corners[2][0] += 10.0; + mapping + .image + .verts + .iter_mut() + .for_each(|vertex| vertex.uv = [0.0, 0.0]); + for (plot, reason) in [ + (pixels, "dimensions"), + (coordinates, "coordinates"), + (mapping, "texture mapping"), + ] { + let error = export_pdf(&page(vec![plot]), &path).unwrap_err(); + assert!( + error.contains("Page 1") && error.contains(reason), + "{error}" + ); + assert_eq!(std::fs::read(&path).unwrap(), b"existing PDF"); + } + std::fs::remove_file(path).unwrap(); +} diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index c15db1277..1efd0825a 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,9 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; +use OpenCADStudio::io::pdf_export::{ + export_pdf, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire, +}; use OpenCADStudio::scene::Scene; #[test] @@ -60,49 +62,38 @@ fn text_and_dim_reach_pdf_export() { .collect(); let p_text = dir.join("with_text.pdf"); - export_pdf( - &plot_wires, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_text, - None, - PdfPlotOptions::default(), - ) - .expect("export with text"); + let mut page = PdfPageInput { + content: PlotContent { + wires: std::sync::Arc::new(plot_wires), + ..Default::default() + }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + }; + export_pdf(&page, &p_text).expect("export with text"); let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = plot_wires - .into_iter() + let stripped: Vec<_> = page + .content + .wires + .iter() + .cloned() .map(|mut w| { w.wire.text_verts.clear(); w }) .collect(); let p_bare = dir.join("no_text.pdf"); - export_pdf( - &stripped, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_bare, - None, - PdfPlotOptions::default(), - ) - .expect("export without text"); + page.content.wires = std::sync::Arc::new(stripped); + export_pdf(&page, &p_bare).expect("export without text"); let no_text = std::fs::read(&p_bare).expect("read pdf"); assert!( 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()); -}