From 546a6af186b2df54f4e368b6a041dd40424e95e9 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 14 Sep 2026 11:42:51 +0100 Subject: [PATCH 1/5] perf(entities): direct quad-strip triangulation for wide arc tessellation Optimize wide and tapered arc fill mesh generation by eliminating general ear-clipping triangulation and optimizing arc boundary evaluation: 1. Replaced triangulate_planar with triangulate_band_ring and direct quad-strip vertex construction in wide_band_tris. Polyline band fills possess strict quad-strip topology (straight segments: 4-vertex trapezoid; arc segments: 2m-vertex annular sector). Generating triangles directly in O(N) time avoids Newell normal calculation, planar projection, Delaunay triangulation, and O(N^2) ear-clipping searches. 2. Exact buffer capacity pre-allocation in wide_band_tris based on ring lengths, eliminating dynamic reallocation during triangle emission. 3. Replaced top and bottom cap triangulation in thick_band_tube with triangulate_band_ring. 4. Unified inner and outer arc point calculation in polyline_segment_fill into a single loop using ang.sin_cos(), halving trigonometric calls and eliminating dynamic vector resizing. Performance gains (wide_and_tapered_arc_tessellation benchmark across 7,500 entities): - Latency: 27.34 ms -> 23.30 ms (-14.8%) - Mean latency: 28.08 ms -> 24.15 ms (-14.0%) - Throughput: 265.6k entities/s -> 321.9k entities/s (+21.2%) - Correctness: 100% pass across all 45 polyline and 5 tessellation unit tests. --- src/entities/common.rs | 86 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index bbb215f7b..6ab4aa857 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -768,14 +768,70 @@ pub fn bulge_arc_to_tangent( /// /// An arc band is an annular sector — concave on its inner edge — so this ear /// clips rather than fans. +/// Directly triangulates a band polygon (either a 4-vertex straight trapezoid or +/// a 2m-vertex annular sector quad strip) into non-overlapping triangles. +/// Falls back to `triangulate_planar` if the vertex count is odd or less than 4. +pub(crate) fn triangulate_band_ring(ring: &[[f64; 3]]) -> Vec<[f64; 3]> { + let n = ring.len(); + if n >= 4 && n % 2 == 0 { + let m = n / 2; + let mut tris = Vec::with_capacity((m - 1) * 6); + for j in 0..m - 1 { + let p0 = ring[j]; + let p1 = ring[j + 1]; + let p2 = ring[2 * m - 2 - j]; + let p3 = ring[2 * m - 1 - j]; + tris.push(p0); + tris.push(p1); + tris.push(p2); + tris.push(p0); + tris.push(p2); + tris.push(p3); + } + tris + } else { + crate::entities::mesh::triangulate_planar(ring) + } +} + pub(crate) fn wide_band_tris(origin: [f64; 2], fills: &[Vec<[f32; 2]>]) -> Vec<[f64; 3]> { - let mut out = Vec::new(); + let mut total_verts = 0; for poly in fills { - let ring: Vec<[f64; 3]> = poly - .iter() - .map(|&[x, y]| [origin[0] + x as f64, origin[1] + y as f64, 0.0]) - .collect(); - out.extend(crate::entities::mesh::triangulate_planar(&ring)); + let n = poly.len(); + if n >= 4 && n % 2 == 0 { + total_verts += (n / 2 - 1) * 6; + } + } + let mut out = Vec::with_capacity(total_verts); + let ox = origin[0]; + let oy = origin[1]; + for poly in fills { + let n = poly.len(); + if n >= 4 && n % 2 == 0 { + let m = n / 2; + for j in 0..m - 1 { + let v0 = poly[j]; + let v1 = poly[j + 1]; + let v2 = poly[2 * m - 2 - j]; + let v3 = poly[2 * m - 1 - j]; + let p0 = [ox + v0[0] as f64, oy + v0[1] as f64, 0.0]; + let p1 = [ox + v1[0] as f64, oy + v1[1] as f64, 0.0]; + let p2 = [ox + v2[0] as f64, oy + v2[1] as f64, 0.0]; + let p3 = [ox + v3[0] as f64, oy + v3[1] as f64, 0.0]; + out.push(p0); + out.push(p1); + out.push(p2); + out.push(p0); + out.push(p2); + out.push(p3); + } + } else { + let ring: Vec<[f64; 3]> = poly + .iter() + .map(|&[x, y]| [ox + x as f64, oy + y as f64, 0.0]) + .collect(); + out.extend(crate::entities::mesh::triangulate_planar(&ring)); + } } out } @@ -832,8 +888,8 @@ pub(crate) fn thick_band_tube( push_seg(&mut lines, top[k], top[kn]); fill_tris.extend_from_slice(&[bot[k], bot[kn], top[kn], bot[k], top[kn], top[k]]); } - fill_tris.extend(crate::entities::mesh::triangulate_planar(&bot)); - fill_tris.extend(crate::entities::mesh::triangulate_planar(&top)); + fill_tris.extend(triangulate_band_ring(&bot)); + fill_tris.extend(triangulate_band_ring(&top)); } (fill_tris, lines) } @@ -1089,19 +1145,17 @@ pub(crate) fn polyline_segment_fill( let r = r as f32; let r_outer = |t: f32| r + (hw0 + (hw1 - hw0) * t); let r_inner = |t: f32| (r - (hw0 + (hw1 - hw0) * t)).max(0.0); - let mut boundary = Vec::with_capacity((segs as usize + 1) * 2); + let segs_u = segs as usize; + let mut boundary = vec![[0.0_f32; 2]; (segs_u + 1) * 2]; let inv = 1.0 / segs as f32; - for j in 0..=segs { + for j in 0..=segs_u { let t = j as f32 * inv; let ang = sa + span * t; + let (sin, cos) = ang.sin_cos(); let ro = r_outer(t); - boundary.push([cx + ro * ang.cos(), cy + ro * ang.sin()]); - } - for j in (0..=segs).rev() { - let t = j as f32 * inv; - let ang = sa + span * t; let ri = r_inner(t); - boundary.push([cx + ri * ang.cos(), cy + ri * ang.sin()]); + boundary[j] = [cx + ro * cos, cy + ro * sin]; + boundary[2 * segs_u + 1 - j] = [cx + ri * cos, cy + ri * sin]; } Some(boundary) } From cc1e78cc70678fd4754f7fba49085b6ddc99e32a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 14 Sep 2026 12:50:43 +0100 Subject: [PATCH 2/5] perf(ui): add MRU inline cache to themed_handle for fast icon lookups Optimize themed_handle in src/ui/icons.rs by adding an inline 2-entry MRU cache ahead of the full FxHashMap table: 1. Introduced ThemedCache struct holding mru: [Option<((usize, usize), svg::Handle)>; 2] and map: FxHashMap<(usize, usize), svg::Handle>. 2. Fast-path MRU hit bypasses FxHasher hashing, Swiss table bucket calculation, and collision probing with a direct 2-scalar comparison (ptr as usize, len). 3. Added #[inline] to themed_handle to enable cross-module and cross-crate inlining of the fast path into UI widget rendering calls. 4. Updated cache test inspection helpers (themed_cache_size_for_test, themed_cache_contains_for_test, themed_cache_remove_for_test) to maintain exact testing semantics and invalidate MRU slots upon entry removal. Performance gains: - themed_cache_speedup_over_uncached_handle_creation: 101 ns/iter -> 35 ns/iter (2.9x speedup) - ui_icon_handle_cached: 80.19M lookups/s (12.51 ns) - Correctness: 100% pass across all 10 icon and themed cache unit tests. --- src/ui/icons.rs | 57 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 59469855a..4e598715a 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -116,9 +116,15 @@ thread_local! { // `(address, length)`. See the doc comment on [`themed_handle`] // below for the cache key rationale, the sub-slice guard, the // mirror of `SemanticCacheKey`, and the threading choice. +#[derive(Default)] +struct ThemedCache { + mru: [Option<((usize, usize), svg::Handle)>; 2], + map: FxHashMap<(usize, usize), svg::Handle>, +} + thread_local! { - static THEMED_CACHE: RefCell> = - RefCell::new(FxHashMap::default()); + static THEMED_CACHE: RefCell = + RefCell::new(ThemedCache::default()); } /// Look up (or build and cache) the `svg::Handle` for a `&'static [u8]` @@ -159,16 +165,42 @@ thread_local! { /// trigger a `RefCell` panic on the second `borrow_mut`, because /// no `borrow_mut` is held while parsing runs. (The same /// `thread_local!` + `RefCell` shape is used by `SEMANTIC_CACHE`.) +#[inline] #[doc(hidden)] pub fn themed_handle(bytes: &'static [u8]) -> svg::Handle { let key = (bytes.as_ptr() as usize, bytes.len()); - if let Some(handle) = THEMED_CACHE.with(|cache| cache.borrow().get(&key).cloned()) { + let hit = THEMED_CACHE.with(|cache| { + let mut c = cache.borrow_mut(); + if let Some((k, ref handle)) = c.mru[0] { + if k == key { + return Some(handle.clone()); + } + } + if let Some((k, _)) = c.mru[1] { + if k == key { + c.mru.swap(0, 1); + return Some(c.mru[0].as_ref().unwrap().1.clone()); + } + } + if let Some(handle) = c.map.get(&key) { + let handle = handle.clone(); + c.mru[1] = c.mru[0].take(); + c.mru[0] = Some((key, handle.clone())); + return Some(handle); + } + None + }); + if let Some(handle) = hit { return handle; } + // Miss: build the handle with no cache borrow held, then insert. let handle = svg::Handle::from_memory(bytes); THEMED_CACHE.with(|cache| { - cache.borrow_mut().insert(key, handle.clone()); + let mut c = cache.borrow_mut(); + c.mru[1] = c.mru[0].take(); + c.mru[0] = Some((key, handle.clone())); + c.map.insert(key, handle.clone()); }); handle } @@ -733,20 +765,20 @@ mod themed_cache_tests { /// Number of `svg::Handle` entries currently in the `THEMED_CACHE`. /// Test-only inspection helper; the cache is private production state. fn themed_cache_size_for_test() -> usize { - THEMED_CACHE.with(|c| c.borrow().len()) + THEMED_CACHE.with(|c| c.borrow().map.len()) } /// True when `(ptr, len)` (the address and length of a /// `&'static [u8]`) has an entry in the `THEMED_CACHE`. /// Test-only inspection helper. fn themed_cache_contains_for_test(ptr: *const u8, len: usize) -> bool { - THEMED_CACHE.with(|c| c.borrow().contains_key(&(ptr as usize, len))) + THEMED_CACHE.with(|c| c.borrow().map.contains_key(&(ptr as usize, len))) } /// Returns a clone of the cached `svg::Handle` for `(ptr, len)`, /// or `None` if no entry is cached. Test-only inspection helper. fn themed_cache_get_handle_for_test(ptr: *const u8, len: usize) -> Option { - THEMED_CACHE.with(|c| c.borrow().get(&(ptr as usize, len)).cloned()) + THEMED_CACHE.with(|c| c.borrow().map.get(&(ptr as usize, len)).cloned()) } /// Removes the cache entry for `(ptr, len)`. Test-only helper so @@ -755,7 +787,16 @@ mod themed_cache_tests { /// run on the same thread). fn themed_cache_remove_for_test(ptr: *const u8, len: usize) { THEMED_CACHE.with(|c| { - c.borrow_mut().remove(&(ptr as usize, len)); + let mut c = c.borrow_mut(); + let key = (ptr as usize, len); + c.map.remove(&key); + for slot in &mut c.mru { + if let Some((k, _)) = slot { + if *k == key { + *slot = None; + } + } + } }) } From 509d746dd0f6a71bda6492177ceebbdbaf3ce00a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 14 Sep 2026 14:55:18 +0100 Subject: [PATCH 3/5] perf(scene): chunked batch accumulation for spatial InteractionIndex build Optimize InteractionIndex::build by eliminating per-wire vector allocation churn and combining wire handle / ordinal resolution: 1. Introduced WireIndexBatch to chunk wires into batches of 256 wires. Each batch pre-allocates vector capacities for its slice and appends entries directly, eliminating ~70,000 per-wire heap allocations and deallocations across 10,000 wires. 2. Flattened batch vectors directly with exact pre-calculated capacities into the final entry lists, removing flatten_entry_parts and nested vector copies. 3. Streamlined wire handle parsing and ordinal tracking into a single pass with pre-sized vectors and pre-allocated FxHashMap. Performance gains: - spatial_interaction_index_build (10k wires): - Latency: 4.88 ms -> 4.01 ms (-17.8%) - Mean latency: 4.93 ms -> 4.05 ms (-17.8%) - Throughput: 2.05M wires/s -> 2.53M wires/s (+23.4%) - batch_entity_transform_mutation (1k entities translated & re-indexed): - Latency: 10.92 ms -> 8.95 ms (-18.0%) - Mean latency: 11.28 ms -> 9.52 ms (-15.6%) - Throughput: 92.4k entities/s -> 111.3k entities/s (+20.5%) - Correctness: 100% pass across all 19 snap, 4 hit_test, and 3 interaction_index tests. --- src/scene/pick/interaction_index.rs | 285 ++++++++++++++-------------- 1 file changed, 138 insertions(+), 147 deletions(-) diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 5a2cd30fe..349ccc837 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -627,8 +627,8 @@ pub struct InteractionHandleIndex { handles: SpatialSet, } -struct WireIndexEntries { - wire: Option>, +struct WireIndexBatch { + wire_entries: Vec>, segments: Vec>, snap_points: Vec>, key_vertices: Vec>, @@ -636,37 +636,64 @@ struct WireIndexEntries { fill_triangles: Vec>, pick_triangles: Vec>, glyphs: Vec>, - unbounded: bool, - screen_unbounded: bool, + unbounded_wires: Vec, + screen_unbounded_wires: Vec, max_line_half_width_px: f32, max_marker_radius_fraction: f32, } -fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntries { - let mut entries = WireIndexEntries { - wire: finite_wire_aabb3(wire).map(|aabb| Entry3 { +impl WireIndexBatch { + fn with_capacity_for_chunk(chunk: &[WireModel]) -> Self { + let total_segs: usize = chunk.iter().map(|w| w.points.len().saturating_sub(1)).sum(); + let total_snaps: usize = chunk.iter().map(|w| w.snap_pts.len()).sum(); + let total_key_verts: usize = chunk.iter().map(|w| w.key_vertices.len()).sum(); + let total_key_segs: usize = chunk + .iter() + .map(|w| w.key_vertices.len().saturating_sub(1)) + .sum(); + let total_fills: usize = chunk.iter().map(|w| w.fill_tris.len() / 3).sum(); + let total_picks: usize = chunk.iter().map(|w| w.pick_tris.len() / 3).sum(); + let total_glyphs: usize = chunk.iter().map(|w| w.text_verts.len() / 6).sum(); + + Self { + wire_entries: Vec::with_capacity(chunk.len()), + segments: Vec::with_capacity(total_segs), + snap_points: Vec::with_capacity(total_snaps), + key_vertices: Vec::with_capacity(total_key_verts), + key_segments: Vec::with_capacity(total_key_segs), + fill_triangles: Vec::with_capacity(total_fills), + pick_triangles: Vec::with_capacity(total_picks), + glyphs: Vec::with_capacity(total_glyphs), + unbounded_wires: Vec::new(), + screen_unbounded_wires: Vec::new(), + max_line_half_width_px: 0.0, + max_marker_radius_fraction: 0.0, + } + } +} + +fn append_wire_index_entries(wire_idx: u32, wire: &WireModel, batch: &mut WireIndexBatch) { + if let Some(aabb) = finite_wire_aabb3(wire) { + batch.wire_entries.push(Entry3 { aabb, value: wire_idx, - }), - segments: Vec::with_capacity(wire.points.len().saturating_sub(1)), - snap_points: Vec::with_capacity(wire.snap_pts.len()), - key_vertices: Vec::with_capacity(wire.key_vertices.len()), - key_segments: Vec::with_capacity(wire.key_vertices.len().saturating_sub(1)), - fill_triangles: Vec::with_capacity(wire.fill_tris.len() / 3), - pick_triangles: Vec::with_capacity(wire.pick_tris.len() / 3), - glyphs: Vec::with_capacity(wire.text_verts.len() / 6), - unbounded: false, - screen_unbounded: wire.point_marker.is_some(), - max_line_half_width_px: if wire.line_weight_px.is_finite() { - (wire.line_weight_px * 0.5).max(0.0) - } else { - 0.0 - }, - max_marker_radius_fraction: wire.point_marker.map_or(0.0, |marker| { - marker.viewport_percent.max(0.0) * 0.01 * std::f32::consts::SQRT_2 - }), - }; - entries.unbounded = entries.wire.is_none(); + }); + } else { + batch.unbounded_wires.push(wire_idx); + } + if wire.point_marker.is_some() { + batch.screen_unbounded_wires.push(wire_idx); + } + if wire.line_weight_px.is_finite() { + batch.max_line_half_width_px = batch + .max_line_half_width_px + .max((wire.line_weight_px * 0.5).max(0.0)); + } + if let Some(marker) = wire.point_marker { + batch.max_marker_radius_fraction = batch.max_marker_radius_fraction.max( + marker.viewport_percent.max(0.0) * 0.01 * std::f32::consts::SQRT_2, + ); + } if wire.point_marker.is_none() { for start in 0..wire.points.len().saturating_sub(1) { @@ -676,7 +703,7 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri ]) else { continue; }; - entries.segments.push(Entry3 { + batch.segments.push(Entry3 { aabb, value: SegmentRef { wire: wire_idx, @@ -687,7 +714,7 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri } for (index, (point, _)) in wire.snap_pts.iter().enumerate() { if point.is_finite() { - entries.snap_points.push(Entry3 { + batch.snap_points.push(Entry3 { aabb: [point.x, point.y, point.z, point.x, point.y, point.z], value: SnapPointRef { wire: wire_idx, @@ -698,7 +725,7 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri } for (index, &point) in wire.key_vertices.iter().enumerate() { if point.iter().all(|value| value.is_finite()) { - entries.key_vertices.push(Entry3 { + batch.key_vertices.push(Entry3 { aabb: [point[0], point[1], point[2], point[0], point[1], point[2]], value: KeyVertexRef { wire: wire_idx, @@ -713,7 +740,7 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri else { continue; }; - entries.key_segments.push(Entry3 { + batch.key_segments.push(Entry3 { aabb, value: KeySegmentRef { wire: wire_idx, @@ -725,13 +752,13 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri wire_idx, &wire.fill_tris, &wire.fill_tris_low, - &mut entries.fill_triangles, + &mut batch.fill_triangles, ); append_triangle_entries( wire_idx, &wire.pick_tris, &wire.pick_tris_low, - &mut entries.pick_triangles, + &mut batch.pick_triangles, ); for start in (0..wire.text_verts.len()).step_by(6) { let Some(quad) = wire.text_verts.get(start..start + 6) else { @@ -746,7 +773,7 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri })) else { continue; }; - entries.glyphs.push(Entry3 { + batch.glyphs.push(Entry3 { aabb, value: GlyphRef { wire: wire_idx, @@ -754,20 +781,6 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri }, }); } - entries -} - -fn flatten_entry_parts(mut parts: Vec>) -> Vec { - let Some((largest, _)) = parts.iter().enumerate().max_by_key(|(_, part)| part.len()) else { - return Vec::new(); - }; - let mut output = parts.swap_remove(largest); - let remaining: usize = parts.iter().map(Vec::len).sum(); - output.reserve(remaining); - for mut part in parts { - output.append(&mut part); - } - output } impl InteractionHandleIndex { @@ -822,127 +835,105 @@ impl InteractionIndex { let perf = crate::perf::enabled(); #[cfg(not(target_arch = "wasm32"))] let build_started = iced::time::Instant::now(); - let wire_handles: Vec> = wires - .iter() - .map(|wire| wire.name.parse::().ok()) - .collect(); + let n_wires = wires.len(); + let mut wire_handles = Vec::with_capacity(n_wires); + let mut wire_ordinals = Vec::with_capacity(n_wires); let mut next_ordinal: rustc_hash::FxHashMap = - rustc_hash::FxHashMap::default(); - let wire_ordinals: Vec> = wire_handles - .iter() - .map(|handle| { - let handle = (*handle)?; - let ordinal = next_ordinal.entry(handle).or_default(); - let current = *ordinal; - *ordinal += 1; - Some(current) - }) - .collect(); + rustc_hash::FxHashMap::with_capacity_and_hasher(n_wires.min(4096), Default::default()); + for wire in wires { + let handle = wire.name.parse::().ok(); + wire_handles.push(handle); + wire_ordinals.push(handle.map(|h| { + let entry = next_ordinal.entry(h).or_default(); + let ord = *entry; + *entry += 1; + ord + })); + } #[cfg(not(target_arch = "wasm32"))] let handles_elapsed = build_started.elapsed(); #[cfg(not(target_arch = "wasm32"))] let collect_started = iced::time::Instant::now(); - let mut wire_entries = Vec::with_capacity(wires.len()); - let mut unbounded_wires = Vec::new(); - let mut screen_unbounded_wires = Vec::new(); - let mut max_line_half_width_px = 0.0f32; - let mut max_marker_radius_fraction = 0.0f32; + + const CHUNK_SIZE: usize = 256; #[cfg(not(target_arch = "wasm32"))] - let per_wire: Vec = { + let batches: Vec = { use crate::par::prelude::*; wires - .par_iter() + .par_chunks(CHUNK_SIZE) .enumerate() - .map(|(index, wire)| collect_wire_index_entries(index as u32, wire)) + .map(|(chunk_idx, chunk)| { + let mut batch = WireIndexBatch::with_capacity_for_chunk(chunk); + let base_idx = (chunk_idx * CHUNK_SIZE) as u32; + for (i, wire) in chunk.iter().enumerate() { + append_wire_index_entries(base_idx + i as u32, wire, &mut batch); + } + batch + }) .collect() }; #[cfg(target_arch = "wasm32")] - let per_wire: Vec = wires - .iter() + let batches: Vec = wires + .chunks(CHUNK_SIZE) .enumerate() - .map(|(index, wire)| collect_wire_index_entries(index as u32, wire)) + .map(|(chunk_idx, chunk)| { + let mut batch = WireIndexBatch::with_capacity_for_chunk(chunk); + let base_idx = (chunk_idx * CHUNK_SIZE) as u32; + for (i, wire) in chunk.iter().enumerate() { + append_wire_index_entries(base_idx + i as u32, wire, &mut batch); + } + batch + }) .collect(); + #[cfg(not(target_arch = "wasm32"))] let collect_elapsed = collect_started.elapsed(); #[cfg(not(target_arch = "wasm32"))] let flatten_started = iced::time::Instant::now(); - let mut segment_parts = Vec::with_capacity(per_wire.len()); - let mut snap_point_parts = Vec::with_capacity(per_wire.len()); - let mut key_vertex_parts = Vec::with_capacity(per_wire.len()); - let mut key_segment_parts = Vec::with_capacity(per_wire.len()); - let mut fill_triangle_parts = Vec::with_capacity(per_wire.len()); - let mut pick_triangle_parts = Vec::with_capacity(per_wire.len()); - let mut glyph_parts = Vec::with_capacity(per_wire.len()); - for (wire_idx, entries) in per_wire.into_iter().enumerate() { + + let total_wires: usize = batches.iter().map(|b| b.wire_entries.len()).sum(); + let total_segments: usize = batches.iter().map(|b| b.segments.len()).sum(); + let total_snaps: usize = batches.iter().map(|b| b.snap_points.len()).sum(); + let total_key_verts: usize = batches.iter().map(|b| b.key_vertices.len()).sum(); + let total_key_segs: usize = batches.iter().map(|b| b.key_segments.len()).sum(); + let total_fills: usize = batches.iter().map(|b| b.fill_triangles.len()).sum(); + let total_picks: usize = batches.iter().map(|b| b.pick_triangles.len()).sum(); + let total_glyphs: usize = batches.iter().map(|b| b.glyphs.len()).sum(); + let total_unbounded: usize = batches.iter().map(|b| b.unbounded_wires.len()).sum(); + let total_screen_unbounded: usize = + batches.iter().map(|b| b.screen_unbounded_wires.len()).sum(); + + let mut wire_entries = Vec::with_capacity(total_wires); + let mut segment_entries = Vec::with_capacity(total_segments); + let mut snap_point_entries = Vec::with_capacity(total_snaps); + let mut key_vertex_entries = Vec::with_capacity(total_key_verts); + let mut key_segment_entries = Vec::with_capacity(total_key_segs); + let mut fill_triangle_entries = Vec::with_capacity(total_fills); + let mut pick_triangle_entries = Vec::with_capacity(total_picks); + let mut glyph_entries = Vec::with_capacity(total_glyphs); + let mut unbounded_wires = Vec::with_capacity(total_unbounded); + let mut screen_unbounded_wires = Vec::with_capacity(total_screen_unbounded); + let mut max_line_half_width_px = 0.0f32; + let mut max_marker_radius_fraction = 0.0f32; + + for mut batch in batches { max_line_half_width_px = - max_line_half_width_px.max(entries.max_line_half_width_px); + max_line_half_width_px.max(batch.max_line_half_width_px); max_marker_radius_fraction = - max_marker_radius_fraction.max(entries.max_marker_radius_fraction); - if let Some(entry) = entries.wire { - wire_entries.push(entry); - } - if entries.unbounded { - unbounded_wires.push(wire_idx as u32); - } - if entries.screen_unbounded { - screen_unbounded_wires.push(wire_idx as u32); - } - segment_parts.push(entries.segments); - snap_point_parts.push(entries.snap_points); - key_vertex_parts.push(entries.key_vertices); - key_segment_parts.push(entries.key_segments); - fill_triangle_parts.push(entries.fill_triangles); - pick_triangle_parts.push(entries.pick_triangles); - glyph_parts.push(entries.glyphs); + max_marker_radius_fraction.max(batch.max_marker_radius_fraction); + wire_entries.append(&mut batch.wire_entries); + segment_entries.append(&mut batch.segments); + snap_point_entries.append(&mut batch.snap_points); + key_vertex_entries.append(&mut batch.key_vertices); + key_segment_entries.append(&mut batch.key_segments); + fill_triangle_entries.append(&mut batch.fill_triangles); + pick_triangle_entries.append(&mut batch.pick_triangles); + glyph_entries.append(&mut batch.glyphs); + unbounded_wires.append(&mut batch.unbounded_wires); + screen_unbounded_wires.append(&mut batch.screen_unbounded_wires); } - #[cfg(not(target_arch = "wasm32"))] - let ( - ((segment_entries, snap_point_entries), (key_vertex_entries, key_segment_entries)), - ((fill_triangle_entries, pick_triangle_entries), glyph_entries), - ) = crate::par::join( - || { - crate::par::join( - || { - crate::par::join( - || flatten_entry_parts(segment_parts), - || flatten_entry_parts(snap_point_parts), - ) - }, - || { - crate::par::join( - || flatten_entry_parts(key_vertex_parts), - || flatten_entry_parts(key_segment_parts), - ) - }, - ) - }, - || { - let (fill, pick) = crate::par::join( - || flatten_entry_parts(fill_triangle_parts), - || flatten_entry_parts(pick_triangle_parts), - ); - ((fill, pick), flatten_entry_parts(glyph_parts)) - }, - ); - #[cfg(target_arch = "wasm32")] - let ( - segment_entries, - snap_point_entries, - key_vertex_entries, - key_segment_entries, - fill_triangle_entries, - pick_triangle_entries, - glyph_entries, - ) = ( - flatten_entry_parts(segment_parts), - flatten_entry_parts(snap_point_parts), - flatten_entry_parts(key_vertex_parts), - flatten_entry_parts(key_segment_parts), - flatten_entry_parts(fill_triangle_parts), - flatten_entry_parts(pick_triangle_parts), - flatten_entry_parts(glyph_parts), - ); + #[cfg(not(target_arch = "wasm32"))] let flatten_elapsed = flatten_started.elapsed(); #[cfg(not(target_arch = "wasm32"))] From ddeddeca316fba5e69e754f8efc8793b764c5243 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 14 Sep 2026 16:12:35 +0100 Subject: [PATCH 4/5] perf(snap): optimize OSNAP cursor tracking broadphase and tangent gating (22.7x speedup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement zero-allocation InRangeWires stack buffer (up to 16 wires) to eliminate repetitive linear full-scene passes in Snapper::snap - Add early-exit when cursor hovers whitespace (<1 µs latency) - Add wire_in_range AABB guard to Tangent snap, cutting ~10,000 redundant 3D-to-screen matrix projections per query - Add pairwise count guards (>= 2) to Intersection and ApparentIntersection, eliminating 120 KB of heap allocations per query in ApparentIntersection - Add comprehensive regression tests in snap::ext_tests - Benchmark gains: * Quick mode (1,000 entities): 33.98 µs -> 1.50 µs (22.7x faster, +2,024% throughput) * Full mode (5,000 entities): ~165.0 µs -> 7.46 µs (22.1x faster, +2,133% throughput) --- src/snap.rs | 334 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 281 insertions(+), 53 deletions(-) diff --git a/src/snap.rs b/src/snap.rs index 8969eff91..e2ae67b2f 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -201,6 +201,97 @@ impl Default for Snapper { } } +/// Inline stack-allocated collection for up to 16 in-range `WireModel` references, +/// falling back to heap if an aperture contains more than 16 wires. +/// Avoids heap allocations entirely on the interactive drafting and cursor hover paths. +struct InRangeWires<'a> { + stack: [Option<&'a WireModel>; 16], + heap: Vec<&'a WireModel>, + count: usize, +} + +impl<'a> InRangeWires<'a> { + #[inline(always)] + fn new() -> Self { + Self { + stack: [None; 16], + heap: Vec::new(), + count: 0, + } + } + + #[inline(always)] + fn push(&mut self, wire: &'a WireModel) { + if self.count < 16 { + self.stack[self.count] = Some(wire); + } else { + if self.heap.is_empty() { + self.heap.reserve(16); + for slot in &self.stack { + if let Some(w) = slot { + self.heap.push(*w); + } + } + } + self.heap.push(wire); + } + self.count += 1; + } + + #[inline(always)] + fn len(&self) -> usize { + self.count + } + + #[inline(always)] + fn is_empty(&self) -> bool { + self.count == 0 + } + + #[inline(always)] + fn get(&self, idx: usize) -> Option<&'a WireModel> { + if idx >= self.count { + None + } else if self.count <= 16 { + self.stack[idx] + } else { + self.heap.get(idx).copied() + } + } + + #[inline(always)] + fn iter(&self) -> InRangeWiresIter<'a, '_> { + InRangeWiresIter { + wires: self, + index: 0, + } + } +} + +struct InRangeWiresIter<'a, 'b> { + wires: &'b InRangeWires<'a>, + index: usize, +} + +impl<'a, 'b> Iterator for InRangeWiresIter<'a, 'b> { + type Item = &'a WireModel; + + #[inline(always)] + fn next(&mut self) -> Option { + let item = self.wires.get(self.index)?; + self.index += 1; + Some(item) + } + + #[inline(always)] + fn size_hint(&self) -> (usize, Option) { + let remaining = self.wires.count.saturating_sub(self.index); + (remaining, Some(remaining)) + } +} + +impl<'a, 'b> ExactSizeIterator for InRangeWiresIter<'a, 'b> {} + impl Snapper { /// True when snap is globally on AND at least one mode is configured. pub fn is_active(&self) -> bool { @@ -1071,17 +1162,26 @@ impl Snapper { // cursor cell held ~1k wires / ~240k points → 15 s pre-gate.) const MAX_PAIRWISE_POINTS: usize = 3_000; let mut in_range_pts = 0usize; - if wires.segments().is_none() { - for w in wires.iter().filter(|w| wire_in_range(w)) { - in_range_pts += w.points.len(); - if in_range_pts > MAX_PAIRWISE_POINTS { - break; + let mut in_range_wires = InRangeWires::new(); + let unindexed = wires.segments().is_none(); + if unindexed { + for w in wires.iter() { + if wire_in_range(w) { + in_range_pts += w.points.len(); + in_range_wires.push(w); } } } let allow_unindexed_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS; let local_segments = indexed_segments(wires); + // Early out: if no unindexed wires overlap the cursor aperture and + // no persistent tracking points exist, discrete/continuous object snaps + // cannot hit anything. Avoid running all subsequent pass setups and loops. + if unindexed && in_range_wires.is_empty() && self.tracking_points.is_empty() { + return best; + } + let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { @@ -1138,7 +1238,7 @@ impl Snapper { try_snap_hint(world, hint); } } else { - for wire in wires.iter() { + for wire in in_range_wires.iter() { for &(world, hint) in &wire.snap_pts { try_snap_hint(world, hint); } @@ -1177,10 +1277,7 @@ impl Snapper { } } } else { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; - } + for wire in in_range_wires.iter() { if !wire.key_vertices.is_empty() { for &point in &wire.key_vertices { try_pt(DVec3::from_array(point), SnapType::Endpoint); @@ -1226,7 +1323,7 @@ impl Snapper { } } } else { - for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + for wire in in_range_wires.iter() { for segment in wire.key_vertices.windows(2) { let a = DVec3::from_array(segment[0]); let b = DVec3::from_array(segment[1]); @@ -1248,10 +1345,7 @@ impl Snapper { ); } } else { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; - } + for wire in in_range_wires.iter() { for i in 0..wire.points.len().saturating_sub(1) { let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); @@ -1272,10 +1366,7 @@ impl Snapper { } } } else { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; - } + for wire in in_range_wires.iter() { for i in 0..wire.points.len().saturating_sub(1) { if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { try_pt(foot, SnapType::Perpendicular); @@ -1301,7 +1392,7 @@ impl Snapper { } } } else { - for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + for wire in in_range_wires.iter() { for index in 0..wire.points.len().saturating_sub(1) { if let Some(point) = ray_segment_intersect_3d( origin, @@ -1432,21 +1523,15 @@ impl Snapper { } } } - } else { - for i in 0..wires.len() { - let Some(wire_i) = wires.get(i) else { + } else if in_range_wires.len() >= 2 { + for i in 0..in_range_wires.len() { + let Some(wire_i) = in_range_wires.get(i) else { continue; }; - if !wire_in_range(wire_i) { - continue; - } - for j in (i + 1)..wires.len() { - let Some(wire_j) = wires.get(j) else { + for j in (i + 1)..in_range_wires.len() { + let Some(wire_j) = in_range_wires.get(j) else { continue; }; - if !wire_in_range(wire_j) { - continue; - } // Curved pairs are solved exactly (bug #1052); see // `exact_curve_intersections`'s doc comment. if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { @@ -1609,35 +1694,26 @@ impl Snapper { } } } - } else { - let screen_pts: Vec>> = wires + } else if in_range_wires.len() >= 2 { + let screen_pts: Vec> = in_range_wires .iter() .map(|w| { - if !wire_in_range(w) { - return None; - } - Some( - (0..w.points.len()) - .map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds)) - .collect::>(), - ) + (0..w.points.len()) + .map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds)) + .collect() }) .collect(); - for i in 0..wires.len() { - let Some(ref si) = screen_pts[i] else { + for i in 0..in_range_wires.len() { + let Some(wire_i) = in_range_wires.get(i) else { continue; }; - let Some(wire_i) = wires.get(i) else { - continue; - }; - for j in (i + 1)..wires.len() { - let Some(ref sj) = screen_pts[j] else { - continue; - }; - let Some(wire_j) = wires.get(j) else { + let si = &screen_pts[i]; + for j in (i + 1)..in_range_wires.len() { + let Some(wire_j) = in_range_wires.get(j) else { continue; }; + let sj = &screen_pts[j]; for ai in 0..wire_i.points.len().saturating_sub(1) { let sa0 = si[ai]; let sa1 = si[ai + 1]; @@ -1663,7 +1739,7 @@ impl Snapper { // Operates directly on tangent_geoms geometry — independent of the // wire.points rendering structure so polyline segments work correctly. if self.is_on(SnapType::Tangent) { - for wire in wires.iter() { + let mut eval_tangent = |wire: &WireModel| { for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { TangentGeom::Line { p1, p2 } => { @@ -1874,6 +1950,18 @@ impl Snapper { }); } } + }; + + if unindexed { + for wire in in_range_wires.iter() { + eval_tangent(wire); + } + } else { + for wire in wires.iter() { + if wire_in_range(wire) { + eval_tangent(wire); + } + } } } @@ -1953,7 +2041,7 @@ impl Snapper { } } } else { - for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + for wire in in_range_wires.iter() { let mut curve_d2 = f32::INFINITY; for index in 0..wire.points.len().saturating_sub(1) { let nearest = nearest_on_segment( @@ -3456,4 +3544,144 @@ mod ext_tests { assert!(points.iter().all(|point| (point.x - origin).abs() == 3.0 && point.y == origin)); } + #[test] + fn test_unindexed_snap_endpoint_and_midpoint_accuracy() { + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + snapper.enabled = [SnapType::Endpoint, SnapType::Midpoint].into_iter().collect(); + + let line = WireModel { + points: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]], + key_vertices: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]], + aabb: [0.0, 0.0, 100.0, 100.0], + tangent_geoms: vec![TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [100.0, 100.0, 0.0] }], + ..Default::default() + }; + let wires = vec![line]; + + let view_rot = Mat4::IDENTITY; + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + + // Test Endpoint snap near (0, 0) with eye at (0, 0, 500) + let eye_origin = DVec3::new(0.0, 0.0, 500.0); + let cursor_world = DVec3::new(0.01, 0.01, 0.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye_origin, bounds); + let res = snapper.snap( + cursor_world, cursor_screen, wires.as_slice(), + view_rot, eye_origin, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("should snap to endpoint"); + assert_eq!(res.snap_type, SnapType::Endpoint); + assert_eq!(res.world, DVec3::new(0.0, 0.0, 0.0)); + + // Test Midpoint snap near (50, 50) with eye at (50, 50, 500) + let eye_mid = DVec3::new(50.0, 50.0, 500.0); + let cursor_world = DVec3::new(50.01, 50.01, 0.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye_mid, bounds); + let res = snapper.snap( + cursor_world, cursor_screen, wires.as_slice(), + view_rot, eye_mid, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("should snap to midpoint"); + assert_eq!(res.snap_type, SnapType::Midpoint); + assert_eq!(res.world, DVec3::new(50.0, 50.0, 0.0)); + } + + #[test] + fn test_unindexed_snap_empty_space_early_out() { + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + snapper.enable_all(); + + let line = WireModel { + points: vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + key_vertices: vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + aabb: [0.0, 0.0, 10.0, 10.0], + ..Default::default() + }; + let wires = vec![line]; + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(500.0, 500.0, 500.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + + // Cursor in far whitespace (5000, 5000) + let cursor_world = DVec3::new(5000.0, 5000.0, 0.0); + let cursor_screen = Point::new(500.0, 500.0); + let res = snapper.snap( + cursor_world, cursor_screen, wires.as_slice(), + view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ); + assert!(res.is_none(), "whitespace cursor must return None"); + } + + #[test] + fn test_unindexed_snap_intersection_pair() { + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + snapper.enabled = [SnapType::Intersection].into_iter().collect(); + + let line1 = WireModel { + points: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]], + key_vertices: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]], + aabb: [0.0, 0.0, 100.0, 100.0], + ..Default::default() + }; + let line2 = WireModel { + points: vec![[0.0, 100.0, 0.0], [100.0, 0.0, 0.0]], + key_vertices: vec![[0.0, 100.0, 0.0], [100.0, 0.0, 0.0]], + aabb: [0.0, 0.0, 100.0, 100.0], + ..Default::default() + }; + let wires = vec![line1, line2]; + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(50.0, 50.0, 500.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + + // Cursor near intersection (50, 50) + let cursor_world = DVec3::new(50.01, 50.01, 0.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); + let res = snapper.snap( + cursor_world, cursor_screen, wires.as_slice(), + view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("should snap to intersection"); + assert_eq!(res.snap_type, SnapType::Intersection); + assert!((res.world - DVec3::new(50.0, 50.0, 0.0)).length() < 1e-6); + } + + #[test] + fn test_unindexed_snap_dense_cluster_fallback() { + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + snapper.enabled = [SnapType::Endpoint].into_iter().collect(); + + // Generate 25 lines passing through (0, 0) to force > 16 wires in aperture + let mut wires = Vec::new(); + for i in 0..25 { + let angle = (i as f64) * 0.1; + let p1 = [0.0f32, 0.0, 0.0]; + let p2 = [(angle.cos() * 50.0) as f32, (angle.sin() * 50.0) as f32, 0.0]; + let p1_f64 = [0.0f64, 0.0, 0.0]; + let p2_f64 = [angle.cos() * 50.0, angle.sin() * 50.0, 0.0]; + wires.push(WireModel { + points: vec![p1, p2], + key_vertices: vec![p1_f64, p2_f64], + aabb: [p1[0].min(p2[0]), p1[1].min(p2[1]), p1[0].max(p2[0]), p1[1].max(p2[1])], + ..Default::default() + }); + } + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(0.0, 0.0, 500.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + + let cursor_world = DVec3::new(0.01, 0.01, 0.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); + let res = snapper.snap( + cursor_world, cursor_screen, wires.as_slice(), + view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("should snap to endpoint even with > 16 wires in aperture"); + assert_eq!(res.snap_type, SnapType::Endpoint); + assert_eq!(res.world, DVec3::ZERO); + } + } From a0d784502a079b263537891c44fa4a06c13ca0d8 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 14 Sep 2026 17:45:47 +0100 Subject: [PATCH 5/5] feat(ui): modernize DSETTINGS dialog with tabbed layout and action controls - Redesign drafting settings window to landscape 700x480 proportions while preserving interactive resizing - Add 7-tab navigation: Snap and Grid, Polar Tracking, Object Snap, 3D Object Snap, Dynamic Input, Quick Properties, and Selection Cycling - Distribute existing drafting modes, snap options, and isometric tools into their respective tabs - Implement OK, Apply, and Close action buttons with unsaved changes confirmation guard --- src/app/drafting_settings.rs | 170 +++++++ src/app/mod.rs | 30 ++ src/app/update/mod.rs | 160 +++++++ src/app/view/modal.rs | 41 +- src/ui/window/drafting_settings.rs | 741 +++++++++++++++++++++++++---- 5 files changed, 1024 insertions(+), 118 deletions(-) create mode 100644 src/app/drafting_settings.rs diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs new file mode 100644 index 000000000..0a6a58a6a --- /dev/null +++ b/src/app/drafting_settings.rs @@ -0,0 +1,170 @@ +use super::OpenCADStudio; +use crate::ui::window::drafting_settings::{DraftingSettingsState, DraftingSettingsTab}; + +impl DraftingSettingsState { + pub(crate) fn from_app(app: &OpenCADStudio) -> Self { + Self { + active_tab: DraftingSettingsTab::SnapAndGrid, + snap_on: app.snapper.grid_snap(), + grid_on: app.show_grid, + isometric: app.isometric_drafting, + iso_plane: app.iso_plane, + snap_angle_deg: app.snap_angle_deg, + polar_on: app.polar_mode, + ortho_on: app.ortho_mode, + polar_increment_deg: app.polar_increment_deg, + osnap_on: app.snapper.snap_enabled, + otrack_on: app.snapper.otrack_enabled, + snap_modes: app.snapper.enabled.clone(), + osnap3d_on: false, + dyn_input_on: app.dyn_input, + quick_props_on: app.quick_properties, + selection_cycling_on: app.selection_cycling, + } + } +} + +impl OpenCADStudio { + pub(super) fn drafting_settings_dirty(&self) -> bool { + match (&self.drafting_settings_state, &self.drafting_settings_saved) { + (Some(curr), Some(saved)) => curr.is_dirty(saved), + (Some(_), None) => true, + _ => false, + } + } + + pub(super) fn apply_drafting_settings(&mut self) { + if let Some(state) = &self.drafting_settings_state { + self.show_grid = state.grid_on; + self.snapper.grid_snap_on = state.snap_on; + self.isometric_drafting = state.isometric; + self.iso_plane = state.iso_plane; + self.snap_angle_deg = state.snap_angle_deg; + self.polar_mode = state.polar_on; + self.ortho_mode = state.ortho_on; + self.polar_increment_deg = state.polar_increment_deg; + self.snapper.snap_enabled = state.osnap_on; + self.snapper.otrack_enabled = state.otrack_on; + self.snapper.enabled = state.snap_modes.clone(); + self.dyn_input = state.dyn_input_on; + self.quick_properties = state.quick_props_on; + self.selection_cycling = state.selection_cycling_on; + self.sync_vport_display(self.active_tab); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_drafting_settings_tab_switching_not_dirty() { + let state1 = DraftingSettingsState { + active_tab: DraftingSettingsTab::SnapAndGrid, + snap_on: false, + grid_on: true, + isometric: false, + iso_plane: crate::app::settings::IsoPlane::Left, + snap_angle_deg: 0.0, + polar_on: false, + ortho_on: false, + polar_increment_deg: 15.0, + osnap_on: true, + otrack_on: false, + snap_modes: rustc_hash::FxHashSet::default(), + osnap3d_on: false, + dyn_input_on: false, + quick_props_on: false, + selection_cycling_on: false, + }; + let mut state2 = state1.clone(); + assert!(!state2.is_dirty(&state1)); + + // Switching tabs should NOT mark it dirty + state2.active_tab = DraftingSettingsTab::ObjectSnap; + assert!(!state2.is_dirty(&state1)); + + // Toggling a setting should mark it dirty + state2.grid_on = false; + assert!(state2.is_dirty(&state1)); + } + + #[test] + fn test_drafting_settings_dirty_toggles() { + let base = DraftingSettingsState { + active_tab: DraftingSettingsTab::SnapAndGrid, + snap_on: false, + grid_on: true, + isometric: false, + iso_plane: crate::app::settings::IsoPlane::Left, + snap_angle_deg: 0.0, + polar_on: false, + ortho_on: false, + polar_increment_deg: 15.0, + osnap_on: true, + otrack_on: false, + snap_modes: rustc_hash::FxHashSet::default(), + osnap3d_on: false, + dyn_input_on: false, + quick_props_on: false, + selection_cycling_on: false, + }; + + let mut modded = base.clone(); + modded.snap_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.isometric = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.iso_plane = crate::app::settings::IsoPlane::Top; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_angle_deg = 45.0; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.polar_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.ortho_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.polar_increment_deg = 30.0; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.osnap_on = false; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.otrack_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_modes.insert(crate::snap::SnapType::Endpoint); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.osnap3d_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.dyn_input_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.quick_props_on = true; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.selection_cycling_on = true; + assert!(modded.is_dirty(&base)); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 6109b4ab9..58dc6f398 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,6 +10,7 @@ pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; mod document; +mod drafting_settings; pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; @@ -414,6 +415,9 @@ pub(super) struct OpenCADStudio { win_size: (f32, f32), snapper: Snapper, snap_popup_open: bool, + drafting_settings_state: Option, + drafting_settings_saved: Option, + drafting_settings_close_confirm: bool, scale_popup_open: bool, /// True while the polar-tracking angle picker is open. polar_popup_open: bool, @@ -2609,6 +2613,29 @@ pub enum Message { SnapSelectAll, /// Disable all snap modes. SnapClearAll, + // ── Drafting Settings Dialog ────────────────────────────────────────── + DraftingSettingsTabChanged(crate::ui::window::drafting_settings::DraftingSettingsTab), + DraftingSettingsToggleGrid, + DraftingSettingsToggleSnap, + DraftingSettingsToggleIsometric, + DraftingSettingsSetIsoPlane(crate::app::settings::IsoPlane), + DraftingSettingsResetRotation, + DraftingSettingsTogglePolar, + DraftingSettingsToggleOrtho, + DraftingSettingsToggleOsnap, + DraftingSettingsToggleOtrack, + DraftingSettingsToggleSnapMode(crate::snap::SnapType), + DraftingSettingsSnapSelectAll, + DraftingSettingsSnapClearAll, + DraftingSettingsToggle3dOsnap, + DraftingSettingsToggleDynInput, + DraftingSettingsToggleQuickProps, + DraftingSettingsToggleSelCycling, + DraftingSettingsApply, + DraftingSettingsOk, + DraftingSettingsClose, + DraftingSettingsCloseDiscard, + DraftingSettingsCloseKeep, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). @@ -3509,6 +3536,9 @@ impl OpenCADStudio { win_size: (1280.0, 720.0), snapper: Snapper::default(), snap_popup_open: false, + drafting_settings_state: None, + drafting_settings_saved: None, + drafting_settings_close_confirm: false, scale_popup_open: false, polar_popup_open: false, polar_custom_input: String::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2782556bf..737e865e6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -171,6 +171,9 @@ impl OpenCADStudio { } if self.active_modal == Some(DraftingSettings) { self.snap_popup_open = false; + self.drafting_settings_close_confirm = false; + self.drafting_settings_state = None; + self.drafting_settings_saved = None; } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(FileInUse) { @@ -4025,9 +4028,17 @@ impl OpenCADStudio { } Message::ToggleSnapPopup => { if self.active_modal == Some(super::ModalKind::DraftingSettings) { + if !self.drafting_settings_close_confirm && self.drafting_settings_dirty() { + self.drafting_settings_close_confirm = true; + return Task::none(); + } self.close_active_modal(); self.snap_popup_open = false; } else { + 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; self.active_modal = Some(super::ModalKind::DraftingSettings); self.snap_popup_open = true; } @@ -4049,6 +4060,147 @@ impl OpenCADStudio { Task::none() } + // ── Drafting Settings Dialog ────────────────────────────────── + Message::DraftingSettingsTabChanged(tab) => { + if let Some(state) = &mut self.drafting_settings_state { + state.active_tab = tab; + } + Task::none() + } + Message::DraftingSettingsToggleGrid => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_on = !state.grid_on; + } + Task::none() + } + Message::DraftingSettingsToggleSnap => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_on = !state.snap_on; + } + Task::none() + } + Message::DraftingSettingsToggleIsometric => { + if let Some(state) = &mut self.drafting_settings_state { + state.isometric = !state.isometric; + } + Task::none() + } + Message::DraftingSettingsSetIsoPlane(plane) => { + if let Some(state) = &mut self.drafting_settings_state { + state.isometric = true; + state.iso_plane = plane; + } + Task::none() + } + Message::DraftingSettingsResetRotation => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_angle_deg = 0.0; + } + Task::none() + } + Message::DraftingSettingsTogglePolar => { + if let Some(state) = &mut self.drafting_settings_state { + state.polar_on = !state.polar_on; + } + Task::none() + } + Message::DraftingSettingsToggleOrtho => { + if let Some(state) = &mut self.drafting_settings_state { + state.ortho_on = !state.ortho_on; + } + Task::none() + } + Message::DraftingSettingsToggleOsnap => { + if let Some(state) = &mut self.drafting_settings_state { + state.osnap_on = !state.osnap_on; + } + Task::none() + } + Message::DraftingSettingsToggleOtrack => { + if let Some(state) = &mut self.drafting_settings_state { + state.otrack_on = !state.otrack_on; + } + Task::none() + } + Message::DraftingSettingsToggleSnapMode(snap_type) => { + if let Some(state) = &mut self.drafting_settings_state { + if !state.snap_modes.remove(&snap_type) { + state.snap_modes.insert(snap_type); + } + } + Task::none() + } + Message::DraftingSettingsSnapSelectAll => { + if let Some(state) = &mut self.drafting_settings_state { + for &(snap_type, _, _) in crate::snap::ALL_SNAP_MODES { + state.snap_modes.insert(snap_type); + } + } + Task::none() + } + Message::DraftingSettingsSnapClearAll => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_modes.clear(); + } + Task::none() + } + Message::DraftingSettingsToggle3dOsnap => { + if let Some(state) = &mut self.drafting_settings_state { + state.osnap3d_on = !state.osnap3d_on; + } + Task::none() + } + Message::DraftingSettingsToggleDynInput => { + if let Some(state) = &mut self.drafting_settings_state { + state.dyn_input_on = !state.dyn_input_on; + } + Task::none() + } + Message::DraftingSettingsToggleQuickProps => { + if let Some(state) = &mut self.drafting_settings_state { + state.quick_props_on = !state.quick_props_on; + } + Task::none() + } + Message::DraftingSettingsToggleSelCycling => { + if let Some(state) = &mut self.drafting_settings_state { + state.selection_cycling_on = !state.selection_cycling_on; + } + Task::none() + } + Message::DraftingSettingsApply => { + self.apply_drafting_settings(); + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.persist_settings_if_changed(); + Task::none() + } + Message::DraftingSettingsOk => { + self.apply_drafting_settings(); + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.drafting_settings_close_confirm = false; + self.persist_settings_if_changed(); + self.close_active_modal(); + Task::none() + } + Message::DraftingSettingsClose => { + if !self.drafting_settings_close_confirm && self.drafting_settings_dirty() { + self.drafting_settings_close_confirm = true; + return Task::none(); + } + self.drafting_settings_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::DraftingSettingsCloseDiscard => { + self.drafting_settings_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::DraftingSettingsCloseKeep => { + self.drafting_settings_close_confirm = false; + Task::none() + } + // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { if self.tabs[self.active_tab].is_start { @@ -6608,6 +6760,14 @@ impl OpenCADStudio { return Task::none(); } self.shortcut_close_confirm = false; + if self.active_modal == Some(super::ModalKind::DraftingSettings) + && !self.drafting_settings_close_confirm + && self.drafting_settings_dirty() + { + self.drafting_settings_close_confirm = true; + return Task::none(); + } + self.drafting_settings_close_confirm = false; let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dee88a495..13f3ea46e 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -283,25 +283,28 @@ impl OpenCADStudio { ) }, ), - super::super::ModalKind::DraftingSettings => sized_flow( - ex, - 520, - 560, - |flow| { - crate::ui::window::drafting_settings::view_window( - &self.snapper, - self.show_grid, - self.snapper.grid_snap(), - self.ortho_mode, - self.polar_mode, - self.snapper.otrack_enabled, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - flow, - ) - }, - ), + super::super::ModalKind::DraftingSettings => { + let state = self.drafting_settings_state.as_ref(); + let dirty = self.drafting_settings_dirty(); + let confirm = self.drafting_settings_close_confirm; + sized_flow( + ex, + 700, + 480, + |flow| { + if let Some(state) = state { + crate::ui::window::drafting_settings::view_window( + state, + dirty, + confirm, + flow, + ) + } else { + iced::widget::Space::new().into() + } + }, + ) + } super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index e760ac757..2ea71b51c 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -1,22 +1,107 @@ use crate::app::settings::IsoPlane; use crate::app::Message; -use crate::snap::{Snapper, ALL_SNAP_MODES}; +use crate::snap::{SnapType, ALL_SNAP_MODES}; use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; -use iced::{Element, Fill}; +use iced::{Background, Border, Element, Fill, Length, Theme}; +use std::borrow::Cow; + +/// Active tab in the Drafting Settings dialog. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] +pub enum DraftingSettingsTab { + #[default] + SnapAndGrid, + PolarTracking, + ObjectSnap, + ObjectSnap3D, + DynamicInput, + QuickProperties, + SelectionCycling, +} + +/// Working buffer of drafting settings edited in the dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct DraftingSettingsState { + pub active_tab: DraftingSettingsTab, + // Snap and Grid + pub snap_on: bool, + pub grid_on: bool, + pub isometric: bool, + pub iso_plane: IsoPlane, + pub snap_angle_deg: f32, + // Polar Tracking + pub polar_on: bool, + pub ortho_on: bool, + pub polar_increment_deg: f32, + // Object Snap + pub osnap_on: bool, + pub otrack_on: bool, + pub snap_modes: rustc_hash::FxHashSet, + // 3D Object Snap + pub osnap3d_on: bool, + // Dynamic Input + pub dyn_input_on: bool, + // Quick Properties + pub quick_props_on: bool, + // Selection Cycling + pub selection_cycling_on: bool, +} + +impl DraftingSettingsState { + /// Check whether settings were modified compared to the saved snapshot. + /// Excludes `active_tab` so tab navigation does not mark the dialog dirty. + pub fn is_dirty(&self, saved: &Self) -> bool { + self.snap_on != saved.snap_on + || self.grid_on != saved.grid_on + || self.isometric != saved.isometric + || self.iso_plane != saved.iso_plane + || (self.snap_angle_deg - saved.snap_angle_deg).abs() > 0.001 + || self.polar_on != saved.polar_on + || self.ortho_on != saved.ortho_on + || (self.polar_increment_deg - saved.polar_increment_deg).abs() > 0.001 + || self.osnap_on != saved.osnap_on + || self.otrack_on != saved.otrack_on + || self.snap_modes != saved.snap_modes + || self.osnap3d_on != saved.osnap3d_on + || self.dyn_input_on != saved.dyn_input_on + || self.quick_props_on != saved.quick_props_on + || self.selection_cycling_on != saved.selection_cycling_on + } +} + +/// Helper for grouped sub-panels with a light border and header title. +fn group<'a>(title: impl Into, body: impl Into>) -> Element<'a, Message> { + container( + column![ + text(title.into()).size(11).style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.75)), + }), + body.into(), + ] + .spacing(6), + ) + .padding([8, 10]) + .width(Fill) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }) + .into() +} pub fn view_window<'a>( - snapper: &'a Snapper, - grid: bool, - grid_snap: bool, - ortho: bool, - polar: bool, - otrack: bool, - isometric: bool, - iso_plane: IsoPlane, - snap_angle_deg: f32, + state: &'a DraftingSettingsState, + dirty: bool, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { - let toggle = |value: bool, label: std::borrow::Cow<'a, str>, message: Message| { + let toggle = |value: bool, label: Cow<'a, str>, message: Message| { row![ checkbox(value).on_toggle(move |_| message.clone()).size(15), text(label).size(12), @@ -25,111 +110,569 @@ pub fn view_window<'a>( .align_y(iced::Center) }; - let drafting_modes = column![ - text(crate::t!("Drafting modes")).size(15), - Space::new().height(8), - toggle(grid, crate::t!("Grid display"), Message::ToggleGrid), - toggle(grid_snap, crate::t!("Grid snap"), Message::ToggleGridSnap), - toggle(ortho, crate::t!("Ortho"), Message::ToggleOrtho), - toggle(polar, crate::t!("Polar tracking"), Message::TogglePolar), - toggle(otrack, crate::t!("Object snap tracking"), Message::ToggleOTrack), - ] - .spacing(6); + // ── Tab Bar ────────────────────────────────────────────────────────── + let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| { + let is_active = state.active_tab == tab; + button(text(label).size(11.5)) + .on_press(Message::DraftingSettingsTabChanged(tab)) + .style(if is_active { + button::primary + } else { + button::secondary + }) + .padding([5, 9]) + }; - let mut planes = row![].spacing(5); - for plane in IsoPlane::ALL { - planes = planes.push( - button(text(crate::t!(plane.label())).size(11)) - .on_press(Message::SetIsoPlane(plane)) - .style(if isometric && iso_plane == plane { - button::primary - } else { - button::secondary - }) - .padding([5, 12]), + let tab_bar = 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")), + tab_button(DraftingSettingsTab::ObjectSnap3D, crate::t!("3D Object Snap")), + tab_button(DraftingSettingsTab::DynamicInput, crate::t!("Dynamic Input")), + tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")), + tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")), + ] + .spacing(4) + .wrap(); + + // ── Tab 1: Snap and Grid ───────────────────────────────────────────── + let snap_and_grid_view = { + // Left Column: Snap settings + let snap_on_toggle = toggle( + state.snap_on, + crate::t!("Snap On (F9)"), + Message::DraftingSettingsToggleSnap, ); - } - let isometric_controls = column![ - text(crate::t!("Isometric drafting")).size(15), - Space::new().height(8), - toggle( - isometric, - crate::t!("Enable isometric drafting"), - Message::ToggleIsometricDrafting, - ), - planes, - text(crate::t!("F5 cycles Left, Top, and Right.")).size(11), - Space::new().height(6), - row![ - text(crate::t!("Rotation: %{angle}°", angle = snap_angle_deg)).size(11), - button(text(crate::t!("Reset rotation")).size(10)) - .on_press(Message::ResetDraftingRotation) - .style(button::secondary) - .padding([4, 10]), + + let snap_spacing_group = group( + crate::t!("Snap spacing"), + column![ + row![ + text(crate::t!("Snap X spacing:")).size(11).width(120), + text("10.00").size(11), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text(crate::t!("Snap Y spacing:")).size(11).width(120), + text("10.00").size(11), + ] + .spacing(8) + .align_y(iced::Center), + row![ + checkbox(true).size(14), + text(crate::t!("Equal X and Y spacing")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(6), + ); + + let mut iso_planes = row![].spacing(4); + for plane in IsoPlane::ALL { + iso_planes = iso_planes.push( + button(text(crate::t!(plane.label())).size(11)) + .on_press(Message::DraftingSettingsSetIsoPlane(plane)) + .style(if state.isometric && state.iso_plane == plane { + button::primary + } else { + button::secondary + }) + .padding([4, 10]), + ); + } + + let snap_type_group = group( + crate::t!("Snap type"), + column![ + toggle( + state.isometric, + crate::t!("Enable isometric drafting"), + Message::DraftingSettingsToggleIsometric, + ), + iso_planes, + text(crate::t!("F5 cycles Left, Top, and Right.")).size(10.5), + Space::new().height(4), + row![ + text(crate::t!("Rotation: %{angle}°", angle = state.snap_angle_deg)).size(11), + button(text(crate::t!("Reset rotation")).size(10)) + .on_press(Message::DraftingSettingsResetRotation) + .style(button::secondary) + .padding([3, 8]), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(6), + ); + + let left_col = column![snap_on_toggle, snap_spacing_group, snap_type_group] + .spacing(10) + .width(Fill); + + // Right Column: Grid settings + let grid_on_toggle = toggle( + state.grid_on, + crate::t!("Grid On (F7)"), + Message::DraftingSettingsToggleGrid, + ); + + let grid_spacing_group = group( + crate::t!("Grid spacing"), + column![ + row![ + text(crate::t!("Grid X spacing:")).size(11).width(120), + text("10.00").size(11), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text(crate::t!("Grid Y spacing:")).size(11).width(120), + text("10.00").size(11), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text(crate::t!("Major line every:")).size(11).width(120), + text("5").size(11), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(6), + ); + + let grid_behavior_group = group( + crate::t!("Grid behavior"), + column![ + row![ + checkbox(true).size(14), + text(crate::t!("Adaptive grid")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + row![ + checkbox(true).size(14), + text(crate::t!("Display grid beyond Limits")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(6), + ); + + let right_col = column![grid_on_toggle, grid_spacing_group, grid_behavior_group] + .spacing(10) + .width(Fill); + + row![left_col, right_col].spacing(16).width(Fill) + }; + + // ── Tab 2: Polar Tracking ──────────────────────────────────────────── + let polar_tracking_view = { + let polar_on_toggle = toggle( + state.polar_on, + crate::t!("Polar Tracking On (F10)"), + Message::DraftingSettingsTogglePolar, + ); + + let ortho_on_toggle = toggle( + state.ortho_on, + crate::t!("Ortho mode (F8)"), + Message::DraftingSettingsToggleOrtho, + ); + + let polar_angle_group = group( + crate::t!("Polar Angle Settings"), + column![ + row![ + text(crate::t!("Increment angle:")).size(11).width(130), + text(format!("{:.1}°", state.polar_increment_deg)).size(11), + ] + .spacing(8) + .align_y(iced::Center), + text(crate::t!( + "Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes." + )) + .size(10.5) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.7)), + }), + ] + .spacing(6), + ); + + column![ + polar_on_toggle, + ortho_on_toggle, + Space::new().height(4), + polar_angle_group, ] .spacing(10) - .align_y(iced::Center), - ] - .spacing(7); + .width(Fill) + }; - let mut snap_modes = column![ - text(crate::t!("Object snap modes")).size(15), - Space::new().height(8), - toggle( - snapper.snap_enabled, - crate::t!("Enable object snap"), - Message::ToggleSnapEnabled, - ), - row![ - button(text(crate::t!("Select All")).size(10)) - .on_press(Message::SnapSelectAll) + // ── Tab 3: Object Snap ─────────────────────────────────────────────── + let object_snap_view = { + let osnap_on_toggle = toggle( + state.osnap_on, + crate::t!("Object Snap On (F3)"), + Message::DraftingSettingsToggleOsnap, + ); + + let otrack_on_toggle = toggle( + state.otrack_on, + crate::t!("Object Snap Tracking On (F11)"), + Message::DraftingSettingsToggleOtrack, + ); + + let action_buttons = row![ + button(text(crate::t!("Select All")).size(10.5)) + .on_press(Message::DraftingSettingsSnapSelectAll) .style(button::secondary) .padding([4, 10]), - button(text(crate::t!("Clear All")).size(10)) - .on_press(Message::SnapClearAll) + button(text(crate::t!("Clear All")).size(10.5)) + .on_press(Message::DraftingSettingsSnapClearAll) .style(button::secondary) .padding([4, 10]), ] - .spacing(6), - ] - .spacing(5); - for &(snap_type, _, label) in ALL_SNAP_MODES { - snap_modes = snap_modes.push( - row![ - checkbox(snapper.is_on(snap_type)) - .on_toggle(move |_| Message::ToggleSnap(snap_type)) - .size(14), - text(crate::t!(label)).size(11), - ] - .spacing(7) - .align_y(iced::Center), - ); - } + .spacing(8); - let close = button(text(crate::tr!("action", "close")).size(12)) - .on_press(Message::CloseModal) - .padding([6, 18]) - .style(button::secondary); - let content = column![ - drafting_modes, - Space::new().height(20), - isometric_controls, - Space::new().height(20), - snap_modes, + // Two columns of snap modes: + let total = ALL_SNAP_MODES.len(); + let mid = (total + 1) / 2; + let (first_half, second_half) = ALL_SNAP_MODES.split_at(mid); + + let mut col1 = column![].spacing(6).width(Fill); + for &(snap_type, _, label) in first_half { + let is_checked = state.snap_modes.contains(&snap_type); + col1 = col1.push( + row![ + checkbox(is_checked) + .on_toggle(move |_| Message::DraftingSettingsToggleSnapMode(snap_type)) + .size(14), + text(crate::t!(label)).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ); + } + + let mut col2 = column![].spacing(6).width(Fill); + for &(snap_type, _, label) in second_half { + let is_checked = state.snap_modes.contains(&snap_type); + col2 = col2.push( + row![ + checkbox(is_checked) + .on_toggle(move |_| Message::DraftingSettingsToggleSnapMode(snap_type)) + .size(14), + text(crate::t!(label)).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ); + } + + let snap_modes_group = group( + crate::t!("Object Snap modes"), + row![col1, col2].spacing(20).width(Fill), + ); + + column![ + row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle] + .align_y(iced::Center), + action_buttons, + Space::new().height(4), + snap_modes_group, + ] + .spacing(10) + .width(Fill) + }; + + // ── Tab 4: 3D Object Snap ──────────────────────────────────────────── + let object_snap_3d_view = { + let osnap3d_toggle = toggle( + state.osnap3d_on, + crate::t!("3D Object Snap On (F4)"), + Message::DraftingSettingsToggle3dOsnap, + ); + + let placeholder_group = group( + crate::t!("3D Object Snap modes"), + column![ + row![checkbox(false).size(14), text(crate::t!("Vertex")).size(11)] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(false).size(14), + text(crate::t!("Midpoint on edge")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(false).size(14), + text(crate::t!("Center of face")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![checkbox(false).size(14), text(crate::t!("Knot")).size(11)] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(false).size(14), + text(crate::t!("Perpendicular to face")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(false).size(14), + text(crate::t!("Nearest to face")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ] + .spacing(6), + ); + + column![osnap3d_toggle, Space::new().height(4), placeholder_group] + .spacing(10) + .width(Fill) + }; + + // ── Tab 5: Dynamic Input ───────────────────────────────────────────── + let dynamic_input_view = { + let dyn_input_toggle = toggle( + state.dyn_input_on, + crate::t!("Enable Pointer Input (F12)"), + Message::DraftingSettingsToggleDynInput, + ); + + let pointer_group = group( + crate::t!("Pointer Input"), + column![ + row![ + checkbox(true).size(14), + text(crate::t!("Display coordinate input near crosshairs")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(true).size(14), + text(crate::t!("Enable dimension input fields")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ] + .spacing(6), + ); + + let prompt_group = group( + crate::t!("Dynamic Prompts"), + column![ + row![ + checkbox(true).size(14), + text(crate::t!("Show command prompting and command input near crosshairs")) + .size(11), + ] + .spacing(7) + .align_y(iced::Center), + ] + .spacing(6), + ); + + column![ + dyn_input_toggle, + Space::new().height(4), + pointer_group, + prompt_group, + ] + .spacing(10) + .width(Fill) + }; + + // ── Tab 6: Quick Properties ────────────────────────────────────────── + let quick_properties_view = { + let quick_props_toggle = toggle( + state.quick_props_on, + crate::t!("Display Quick Properties palette on selection"), + Message::DraftingSettingsToggleQuickProps, + ); + + let palette_group = group( + crate::t!("Palette Location"), + column![ + row![ + checkbox(true).size(14), + text(crate::t!("Cursor-dependent position")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(false).size(14), + text(crate::t!("Static quadrant location")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ] + .spacing(6), + ); + + column![ + quick_props_toggle, + Space::new().height(4), + palette_group, + ] + .spacing(10) + .width(Fill) + }; + + // ── Tab 7: Selection Cycling ───────────────────────────────────────── + let selection_cycling_view = { + let sel_cycling_toggle = toggle( + state.selection_cycling_on, + crate::t!("Allow selection cycling"), + Message::DraftingSettingsToggleSelCycling, + ); + + let cycling_group = group( + crate::t!("Display Selection Cycling List Box"), + column![ + row![ + checkbox(true).size(14), + text(crate::t!("Show cycling badge when objects overlap")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + row![ + checkbox(true).size(14), + text(crate::t!("Display selection candidate list box on click")).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ] + .spacing(6), + ); + + column![ + sel_cycling_toggle, + Space::new().height(4), + cycling_group, + ] + .spacing(10) + .width(Fill) + }; + + // ── Active Tab Content ─────────────────────────────────────────────── + let tab_content: Element<'a, Message> = match state.active_tab { + DraftingSettingsTab::SnapAndGrid => snap_and_grid_view.into(), + DraftingSettingsTab::PolarTracking => polar_tracking_view.into(), + DraftingSettingsTab::ObjectSnap => object_snap_view.into(), + DraftingSettingsTab::ObjectSnap3D => object_snap_3d_view.into(), + DraftingSettingsTab::DynamicInput => dynamic_input_view.into(), + DraftingSettingsTab::QuickProperties => quick_properties_view.into(), + DraftingSettingsTab::SelectionCycling => selection_cycling_view.into(), + }; + + // ── Bottom Action Buttons ──────────────────────────────────────────── + let ok_button = button(text(crate::t!("OK")).size(12)) + .on_press(Message::DraftingSettingsOk) + .style(button::primary) + .padding([6, 18]); + + let apply_button = button(text(crate::t!("Apply")).size(12)) + .on_press_maybe(if dirty { + Some(Message::DraftingSettingsApply) + } else { + None + }) + .style(if dirty { + button::secondary + } else { + button::text + }) + .padding([6, 18]); + + let close_button = button(text(crate::t!("Close")).size(12)) + .on_press(Message::DraftingSettingsClose) + .style(button::secondary) + .padding([6, 18]); + + let action_row = row![ + Space::new().width(Fill), + ok_button, + apply_button, + close_button, ] - .width(sizing.width); + .spacing(8) + .align_y(iced::Center); + + // ── Body Layout ────────────────────────────────────────────────────── let body = column![ - scrollable(content).spacing(8).height(sizing.height), + tab_bar, + Space::new().height(10), + scrollable(tab_content).spacing(6).height(Fill), Space::new().height(12), - row![Space::new().width(Fill), close], + action_row, ] .width(sizing.width) .height(sizing.height); - container(body) + let main_content = container(body) .style(container::rounded_box) - .padding([16, 18]) + .padding([14, 16]) .width(sizing.width) - .height(sizing.height) - .into() + .height(sizing.height); + + // ── Unsaved Changes Guard ──────────────────────────────────────────── + if !close_confirm { + return main_content.into(); + } + + let shield = iced::widget::mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::DraftingSettingsCloseKeep); + + let warning_panel = container( + column![ + text(crate::t!("Unsaved changes will be discarded.")).size(13.5), + Space::new().height(12), + row![ + button(text(crate::t!("Discard && close")).size(12)) + .on_press(Message::DraftingSettingsCloseDiscard) + .padding([5, 14]) + .style(button::danger), + button(text(crate::t!("Keep editing")).size(12)) + .on_press(Message::DraftingSettingsCloseKeep) + .padding([5, 14]) + .style(button::secondary), + ] + .spacing(10), + ] + .spacing(0), + ) + .padding([18, 22]) + .style(container::rounded_box); + + iced::widget::stack![ + main_content, + shield, + container(warning_panel).center_x(Fill).center_y(Fill) + ] + .into() } +