From e29b408bda5e7e24b5c1d516cb95bce50abc9150 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:52:13 +0300 Subject: [PATCH 01/11] WIP count feature --- Cargo.lock | 2 +- Cargo.toml | 3 + src/app/commands/count.rs | 728 ++++++++++++++++++++++++++++++ src/app/commands/field.rs | 9 + src/app/commands/inquiry.rs | 39 -- src/app/commands/mod.rs | 4 + src/app/document.rs | 3 + src/app/mod.rs | 3 + src/app/settings.rs | 20 + src/app/update/dialog.rs | 5 + src/app/update/file.rs | 8 +- src/app/update/mod.rs | 3 + src/app/view/mod.rs | 15 + src/modules/annotate/count_cmd.rs | 395 ++++++++++++++++ src/modules/annotate/field_cmd.rs | 11 +- src/modules/annotate/mod.rs | 1 + src/modules/view/count_palette.rs | 11 + src/modules/view/mod.rs | 2 + src/scene/mod.rs | 38 +- src/ui/dock.rs | 5 + src/ui/popup/context_menu.rs | 6 + src/ui/ribbon/mod.rs | 7 + src/ui/ribbon/widgets.rs | 2 + src/ui/window/count_palette.rs | 565 +++++++++++++++++++++++ src/ui/window/field_dialog.rs | 45 +- src/ui/window/mod.rs | 1 + 26 files changed, 1887 insertions(+), 44 deletions(-) create mode 100644 src/app/commands/count.rs create mode 100644 src/modules/annotate/count_cmd.rs create mode 100644 src/modules/view/count_palette.rs create mode 100644 src/ui/window/count_palette.rs diff --git a/Cargo.lock b/Cargo.lock index 51294fb85..cb79660da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d96e3fa#d96e3fa2fe5acbeac966f1db4c01142618bf9c79" +source = "git+https://github.com/ramox81/cadcodec.git?rev=94095f7#94095f76fda8cd2596174b2ee9845d568e5da3fb" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 85cc56ce7..ea12cedbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,9 @@ objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", " block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } +[patch."https://github.com/HakanSeven12/opencadcodec.git"] +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "94095f7" } + [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs new file mode 100644 index 000000000..4a30a0c10 --- /dev/null +++ b/src/app/commands/count.rs @@ -0,0 +1,728 @@ +//! COUNT and its family: count mode (COUNT, COUNTAREA, COUNTCLOSE), the +//! Count palette (COUNTLIST, COUNTLISTCLOSE), count fields (COUNTFIELD, +//! UPDATEFIELD), count tables (COUNTTABLE) and the COUNT* variables. + +use crate::app::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::modules::annotate::count_cmd::{CountCommand, CountTableCommand, UpdateFieldCommand}; +use crate::modules::annotate::field_cmd::FieldPlaceCommand; +use crate::ui::window::count_palette::{aci_rgba, CountMode, CountMsg, CountTarget, RowAction, AREA_LAYER}; +use codec::count::{area_json, block_json, single_json, CountKey}; +use codec::{EntityType, Handle}; +use iced::Task; + +/// The variables this family answers for. +pub(super) const COUNT_SYSVARS: &[&str] = + &["COUNTNUMBER", "COUNTSERVICE", "COUNTPALETTESTATE", "COUNTCOLOR", "COUNTERRORCOLOR"]; + +fn hex(h: Handle) -> String { + format!("{:X}", h.value()) +} + +fn parse_handle(s: &str) -> Option { + u64::from_str_radix(s.trim(), 16).ok().map(Handle::new) +} + +fn parse_xy(s: &str) -> Option<[f64; 2]> { + let (x, y) = s.split_once(',')?; + Some([x.trim().parse().ok()?, y.trim().parse().ok()?]) +} + +/// A field referring to `boundary` keeps the count area's polyline. +fn boundary_in_use(doc: &codec::CadDocument, boundary: Handle) -> bool { + let needle = format!("\"boundaryObjectHandle\":\"{}\"", hex(boundary)); + doc.fields.values().any(|f| f.code.contains(&needle)) +} + +impl OpenCADStudio { + pub(super) fn dispatch_count(&mut self, cmd: &str, i: usize) -> Option> { + let cmd = cmd.trim(); + let (verb, rest) = cmd.split_once(char::is_whitespace).unwrap_or((cmd, "")); + let verb = verb.to_ascii_uppercase(); + let rest = rest.trim(); + if verb == "SETVAR" { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let name = name.to_ascii_uppercase(); + if !COUNT_SYSVARS.contains(&name.as_str()) { + return None; + } + self.count_sysvar(i, &name, Some(value.trim()).filter(|v| !v.is_empty())); + return Some(Task::none()); + } + if COUNT_SYSVARS.contains(&verb.as_str()) { + self.count_sysvar(i, &verb, Some(rest).filter(|v| !v.is_empty())); + return Some(Task::none()); + } + match verb.as_str() { + "COUNT" => { + let preselected = self.tabs[i].scene.selected_handles_in_order(); + let current = matches!(rest.trim_start_matches('_').to_ascii_uppercase().as_str(), "C"); + if current && !preselected.is_empty() { + // Count Selection: the selection is the target. + self.command_line.push_output(&format!("{} found", preselected.len())); + let handles: Vec = preselected.iter().map(|h| hex(*h)).collect(); + self.tabs[i].scene.deselect_all(); + return Some(self.dispatch_command(&format!("_COUNTRUN C T {}", handles.join(",")))); + } + // Plain COUNT asks for its targets itself. + self.tabs[i].scene.deselect_all(); + let command = if current { CountCommand::with_area("C") } else { CountCommand::new(false) }; + Some(self.start_count_command(i, Box::new(command), "COUNT")) + } + "COUNTAREA" => Some(self.start_count_command(i, Box::new(CountCommand::new(true)), "COUNTAREA")), + "COUNTCLOSE" => { + self.close_count(i); + Some(Task::none()) + } + "COUNTLIST" => { + self.set_count_palette(true); + Some(Task::none()) + } + "COUNTLISTCLOSE" => { + self.set_count_palette(false); + Some(Task::none()) + } + "_COUNTLISTTOGGLE" => { + let open = !self.count_palette.show; + self.set_count_palette(open); + Some(Task::none()) + } + "COUNTFIELD" => { + match self.count_field_code(i) { + Some(code) => return Some(self.start_count_field(i, code)), + None if self.tabs[i].count.is_some() => {} + None => self.command_line.push_error("** COUNTFIELD command only available during Count. **"), + } + Some(Task::none()) + } + "COUNTTABLE" => { + let doc = &self.tabs[i].scene.document; + let mut blocks = Vec::new(); + let mut others = [0usize; 3]; + for br in doc.block_records.iter() { + let upper = br.name.to_ascii_uppercase(); + if upper == "*MODEL_SPACE" || upper.starts_with("*PAPER_SPACE") { + continue; + } + if br.flags.is_xref || br.flags.is_xref_overlay { + others[0] += 1; + } else if br.name.contains('|') { + others[1] += 1; + } else if br.name.starts_with('*') { + others[2] += 1; + } else { + blocks.push(br.name.clone()); + } + } + blocks.sort_by_key(|b| b.to_ascii_uppercase()); + Some(self.start_count_command(i, Box::new(CountTableCommand::new(blocks, others)), "COUNTTABLE")) + } + "UPDATEFIELD" => { + let preselected = self.tabs[i].scene.selected_handles_in_order(); + if !preselected.is_empty() { + self.update_fields(i, &preselected); + return Some(Task::none()); + } + Some(self.start_count_command(i, Box::new(UpdateFieldCommand::new()), "UPDATEFIELD")) + } + "_UPDATEFIELDRUN" => { + let handles: Vec = rest.split(',').filter_map(parse_handle).collect(); + self.tabs[i].scene.deselect_all(); + self.update_fields(i, &handles); + Some(Task::none()) + } + "_COUNTRUN" => { + self.count_run(i, rest); + Some(Task::none()) + } + "_COUNTAREASET" => { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + if let Some((area, boundary)) = self.resolve_count_area(i, &tokens) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + if mode.boundary != boundary { + self.drop_count_boundary(i, mode.boundary); + } + mode.area = area; + mode.boundary = boundary; + mode.cursor = None; + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + self.command_line.push_output("count area is active."); + } + Some(Task::none()) + } + "_COUNTTABLEPLACE" => { + let (point, names) = rest.split_once(' ').unwrap_or((rest, "")); + let p: Vec = point.split(',').filter_map(|v| v.parse().ok()).collect(); + if p.len() == 3 { + let names: Vec = names.split('|').filter(|n| !n.is_empty()).map(str::to_string).collect(); + self.place_count_table(i, [p[0], p[1], p[2]], names); + } + Some(Task::none()) + } + _ => None, + } + } + + fn start_count_command(&mut self, i: usize, command: Box, _name: &str) -> Task { + self.reset_command_start_state(i); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(command); + self.push_ucs_to_cmd(i); + self.sync_dyn_fields(); + self.focus_cmd_input() + } + + /// COUNTNUMBER / COUNTPALETTESTATE are read-only; COUNTSERVICE, + /// COUNTCOLOR and COUNTERRORCOLOR take a value (asked for when none is + /// given). + fn count_sysvar(&mut self, i: usize, name: &str, value: Option<&str>) { + let read_only = match name { + "COUNTNUMBER" => Some( + self.tabs[i] + .count + .as_ref() + .filter(|m| m.target.is_some()) + .map(|m| m.result(&self.tabs[i].scene.document).counted.len()) + .unwrap_or(0), + ), + "COUNTPALETTESTATE" => Some(self.count_palette.show as usize), + _ => None, + }; + if let Some(v) = read_only { + self.command_line.push_output(&format!("{name} = {v} (read only)")); + return; + } + let (current, min, max) = match name { + "COUNTSERVICE" => (self.count_palette.service as i32, 0, 1), + "COUNTCOLOR" => (self.count_palette.color as i32, 1, 255), + _ => (self.count_palette.error_color as i32, 1, 255), + }; + let ask = |app: &mut Self| { + app.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + app.pending_setvar = Some(name.to_string()); + }; + let Some(value) = value else { + ask(self); + return; + }; + match value.parse::() { + Ok(n) if (min..=max).contains(&n) => { + match name { + "COUNTSERVICE" => self.count_palette.service = n == 1, + "COUNTCOLOR" => self.count_palette.color = n as i16, + _ => self.count_palette.error_color = n as i16, + } + self.persist_settings_if_changed(); + self.apply_count_display(i); + } + Ok(_) => { + self.command_line.push_error(&if max == 1 { + "Requires 0 or 1 only.".to_string() + } else { + format!("Requires an integer between {min} and {max}.") + }); + ask(self); + } + Err(_) => { + self.command_line.push_error("Requires an integer value."); + ask(self); + } + } + } + + /// Open or close the Count palette (docked on the right, expanded). + pub(in crate::app) fn set_count_palette(&mut self, open: bool) { + let id = crate::ui::dock::PanelId::Count; + self.count_palette.show = open; + self.ribbon.set_count_palette(open); + if open { + if self.dock.location(id).is_none() { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_expanded = Some(id); + } else if self.dock_expanded == Some(id) { + self.dock_expanded = None; + } + } + + /// Recolour the drawing for count mode: counted references, duplicates, + /// the rest faded (nothing changes without a target). + pub(in crate::app) fn apply_count_display(&mut self, i: usize) { + let display = self.tabs[i].count.as_ref().filter(|m| m.target.is_some()).map(|m| { + let r = m.result(&self.tabs[i].scene.document); + crate::scene::CountDisplay { + counted: r.counted.into_iter().collect(), + errors: r.errors.into_iter().collect(), + color: aci_rgba(self.count_palette.color), + error_color: aci_rgba(self.count_palette.error_color), + } + }); + self.tabs[i].scene.set_count_display(display); + let epoch = self.tabs[i].scene.geometry_epoch; + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.epoch = epoch; + } + } + + /// After the drawing changes, count mode colours its references again. + pub(in crate::app) fn refresh_count_if_stale(&mut self) { + let i = self.active_tab; + if self.tabs[i].count.as_ref().is_some_and(|m| m.epoch != self.tabs[i].scene.geometry_epoch) { + self.apply_count_display(i); + } + } + + /// COUNTCLOSE: leave count mode; the area polyline count mode drew goes + /// unless a field counts in it. + pub(in crate::app) fn close_count(&mut self, i: usize) { + if let Some(mode) = self.tabs[i].count.take() { + self.drop_count_boundary(i, mode.boundary); + } + self.tabs[i].scene.set_count_display(None); + } + + fn drop_count_boundary(&mut self, i: usize, boundary: Option<(Handle, bool)>) { + if let Some((h, true)) = boundary { + let doc = &self.tabs[i].scene.document; + if doc.get_entity(h).is_some() && !boundary_in_use(doc, h) { + self.push_undo_snapshot(i, "COUNT"); + self.tabs[i].scene.erase_entities(&[h]); + self.tabs[i].dirty = true; + } + } + } + + /// The count area of `C`, `E`, `K`, `R a b`, `P a b c …` or `O h`, and + /// its boundary polyline (rectangles and polygons are drawn on the + /// `0-CountArea` layer). `None` when the area cannot be had. + fn resolve_count_area( + &mut self, + i: usize, + tokens: &[&str], + ) -> Option<(Option>, Option<(Handle, bool)>)> { + let kind = tokens.first().copied().unwrap_or("E"); + let points = || tokens[1..].iter().map_while(|t| parse_xy(t)).collect::>(); + match kind { + "E" => Some((None, None)), + "K" => { + let mode = self.tabs[i].count.as_ref(); + Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary))) + } + "C" => { + let (x0, y0, x1, y1) = self.display_plot_window()?; + Some((Some(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]), None)) + } + "R" => { + let p = points(); + let (a, b) = (p.first()?, p.get(1)?); + let ring = vec![[a[0], a[1]], [b[0], a[1]], [b[0], b[1]], [a[0], b[1]]]; + let h = self.draw_count_area(i, &ring)?; + Some((Some(ring), Some((h, true)))) + } + "P" => { + let ring = points(); + (ring.len() >= 3).then_some(())?; + let h = self.draw_count_area(i, &ring)?; + Some((Some(ring), Some((h, true)))) + } + "O" => { + let h = parse_handle(tokens.get(1)?)?; + let ring = codec::count::boundary_polygon(&self.tabs[i].scene.document, h)?; + Some((Some(ring), Some((h, false)))) + } + _ => None, + } + } + + /// A closed polyline on `0-CountArea` (colour 152, not plotted, + /// Continuous), whatever the current layer. + fn draw_count_area(&mut self, i: usize, ring: &[[f64; 2]]) -> Option { + self.push_undo_snapshot(i, "COUNT"); + let doc = &mut self.tabs[i].scene.document; + if !doc.layers.contains(AREA_LAYER) { + let mut layer = codec::tables::Layer::new(AREA_LAYER); + layer.handle = doc.allocate_handle(); + layer.color = codec::types::Color::from_index(152); + layer.is_plottable = false; + layer.line_type = "Continuous".into(); + let _ = doc.layers.add(layer); + } + let mut pl = codec::entities::LwPolyline::new(); + pl.vertices = ring + .iter() + .map(|p| codec::entities::LwVertex::new(codec::types::Vector2::new(p[0], p[1]))) + .collect(); + pl.is_closed = true; + pl.common.layer = AREA_LAYER.into(); + let h = self.commit_entity_handle_preserve_layer(EntityType::LwPolyline(pl)); + self.tabs[i].dirty = true; + h + } + + /// `_COUNTRUN L` opens the palette; `_COUNTRUN T ` + /// counts the targets and enters count mode. + fn count_run(&mut self, i: usize, rest: &str) { + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let split = tokens.iter().position(|t| *t == "L" || *t == "T").unwrap_or(tokens.len()); + let (area_tokens, tail) = tokens.split_at(split); + let targets: Vec = match tail { + ["T", handles, ..] => handles + .split(',') + .filter_map(parse_handle) + .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) + .collect(), + _ => Vec::new(), + }; + if targets.is_empty() && matches!(area_tokens.first(), Some(&"C") | Some(&"E") | None) { + self.set_count_palette(true); + return; + } + let Some((area, boundary)) = self.resolve_count_area(i, area_tokens) else { + return; + }; + let doc = &self.tabs[i].scene.document; + let blocks: Vec<&str> = targets + .iter() + .filter_map(|h| match doc.get_entity(*h) { + Some(EntityType::Insert(ins)) => Some(ins.block_name.as_str()), + _ => None, + }) + .collect(); + let target = match blocks.first() { + Some(first) if blocks.len() == targets.len() && blocks.iter().all(|b| b.eq_ignore_ascii_case(first)) => { + let name = doc.block_records.get(first).map(|b| b.name.clone()).unwrap_or_else(|| first.to_string()); + Some(CountTarget::Block { name, reference: targets.first().copied(), matching: [false; 3], picked: true }) + } + None if targets.is_empty() => None, + _ => Some(CountTarget::Group(targets.clone())), + }; + let old = self.tabs[i].count.take(); + if let Some(old) = old.as_ref().filter(|o| o.boundary != boundary) { + self.drop_count_boundary(i, old.boundary); + } + let mode = CountMode { area, boundary, target, cursor: None, epoch: 0 }; + if let Some(name) = mode.target_name() { + let n = mode.result(&self.tabs[i].scene.document).counted.len(); + self.command_line.push_output(&format!("{name} ...... {n}")); + } + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + } + + /// The field COUNTFIELD places for the current count: the picked + /// reference (`single`), or the block with the match options (in the + /// area's polyline when there is one). + fn count_field_code(&self, i: usize) -> Option { + let mode = self.tabs[i].count.as_ref()?; + match mode.target.as_ref()? { + CountTarget::Group(handles) => Some(format!("\\AcCount {}", single_json(handles))), + CountTarget::Block { reference: Some(r), matching: [false, false, false], picked: true, .. } + if mode.boundary.is_none() => + { + Some(format!("\\AcCount {}", single_json(&[*r]))) + } + CountTarget::Block { name, .. } => { + let instances = mode.instances(&self.tabs[i].scene.document); + Some(self.block_field_code(i, name, &mode.key(&instances))) + } + } + } + + /// `\AcCount` of a block and key, `\AcCount2` inside count mode's area polyline. + fn block_field_code(&self, i: usize, name: &str, key: &CountKey) -> String { + match self.tabs[i].count.as_ref().and_then(|m| m.boundary) { + Some((boundary, _)) => format!("\\AcCount2 {}", area_json(name, key, boundary)), + None => format!("\\AcCount {}", block_json(name, key)), + } + } + + /// Place a count field as multi-line text (`Specify start point or [Height/Justify]:`). + pub(in crate::app) fn start_count_field(&mut self, i: usize, code: String) -> Task { + let doc = &self.tabs[i].scene.document; + let value = crate::entities::field::evaluate(doc, &code, &[], None).unwrap_or_else(|| "####".into()); + let defaults = crate::scene::creation_style::current_text_defaults(doc); + self.command_line.push_output(&format!( + "MTEXT Current text style: \"{}\" Text height: {:.4}", + defaults.style_name, defaults.height + )); + let command = FieldPlaceCommand::new(value, (code, Vec::new()), defaults.style_name, defaults.height).named("MTEXT"); + self.start_count_command(i, Box::new(command), "COUNTFIELD") + } + + /// UPDATEFIELD: every field the objects host is evaluated again and its + /// text written back. + fn update_fields(&mut self, i: usize, handles: &[Handle]) { + let doc = &self.tabs[i].scene.document; + let mut updates: Vec<(Handle, String)> = Vec::new(); + let mut found = 0; + for h in handles { + let Some(entity) = doc.get_entity(*h) else { continue }; + if matches!(entity, EntityType::Table(_)) { + // Table cells evaluate their fields when drawn. + if let EntityType::Table(t) = entity { + found += t.field_handles.len(); + } + continue; + } + if !crate::entities::field::hosts_field(doc, entity) { + continue; + } + found += 1; + if let Some(text) = crate::entities::field::resolve(doc, *h) { + updates.push((*h, text)); + } + } + self.command_line.push_output(&format!("{found} field(s) found.")); + if found == 0 { + return; + } + self.push_undo_snapshot(i, "UPDATEFIELD"); + let mut changed = Vec::new(); + for (h, text) in updates { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(h) { + match entity { + EntityType::MText(m) => m.value = text, + EntityType::Text(t) => t.value = text, + EntityType::AttributeDefinition(a) => a.default_value = text, + _ => continue, + } + changed.push((h, crate::scene::ChangeKind::Modified)); + } + } + let table_handles: Vec<(Handle, crate::scene::ChangeKind)> = handles + .iter() + .filter(|h| matches!(self.tabs[i].scene.document.get_entity(**h), Some(EntityType::Table(_)))) + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + changed.extend(table_handles); + self.tabs[i].scene.bump_entities(&changed); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("{found} field(s) updated.")); + } + + /// A count table at `point`: `Item` | `Count`, one row per block (all + /// counted blocks when `names` is empty), sorted by name; the counts are + /// fields. + fn place_count_table(&mut self, i: usize, point: [f64; 3], names: Vec) { + let doc = &self.tabs[i].scene.document; + let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); + let instances = codec::count::block_instances(doc, area.as_deref()); + let mut names: Vec = if names.is_empty() { + codec::count::block_counts(&instances).into_iter().map(|c| c.name).collect() + } else { + names + .iter() + .map(|n| doc.block_records.get(n).map(|b| b.name.clone()).unwrap_or_else(|| n.clone())) + .collect() + }; + names.sort_by_key(|n| n.to_ascii_uppercase()); + names.dedup_by_key(|n| n.to_ascii_uppercase()); + let mut table = codec::entities::TableBuilder::new(names.len() + 1, 2) + .at(codec::types::Vector3::new(point[0], point[1], point[2])) + .row_height(0.36) + .column_width(3.6) + .build(); + table.set_column_width(1, 1.08); + table.set_cell_text(0, 0, "Item"); + table.set_cell_text(0, 1, "Count"); + for (r, name) in names.iter().enumerate() { + table.set_cell_text(r + 1, 0, name); + } + let codes: Vec = + names.iter().map(|n| self.block_field_code(i, n, &CountKey::default())).collect(); + self.push_undo_snapshot(i, "COUNTTABLE"); + let Some(handle) = self.commit_entity_handle(EntityType::Table(Box::new(table))) else { + return; + }; + let doc = &mut self.tabs[i].scene.document; + let mut fields = Vec::new(); + for code in &codes { + let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into()); + let field = doc.new_table_cell_field(handle, "%<\\_FldIdx 0>%", vec![codec::fields::NewField::new(code.clone(), value.clone())]); + fields.push((field, value)); + } + if let Some(EntityType::Table(table)) = doc.get_entity_mut(handle) { + for (r, (field, value)) in fields.into_iter().enumerate() { + let Some(field) = field else { continue }; + if let Some(cell) = table.cell_mut(r + 1, 1) { + let mut content = codec::entities::table::CellContent::text(&value); + content.field_handle = Some(field); + cell.contents.clear(); + cell.contents.push(content); + cell.cell_type = codec::entities::table::CellType::Text; + } + table.field_handles.push(field); + } + } + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } + + /// Zoom to a reference (← / →, an error of the report). + fn zoom_to_insert(&mut self, i: usize, h: Handle) { + let doc = &self.tabs[i].scene.document; + let Some(EntityType::Insert(ins)) = doc.get_entity(h) else { return }; + let Some(corners) = codec::count::insert_corners(doc, ins, 0) else { return }; + let (mut lo, mut hi) = (glam::Vec3::splat(f32::MAX), glam::Vec3::splat(f32::MIN)); + for c in corners { + let p = glam::Vec3::new(c.x as f32, c.y as f32, c.z as f32); + lo = lo.min(p); + hi = hi.max(p); + } + let pad = (hi - lo) * 1.5; + self.tabs[i].scene.remember_current_view(); + self.tabs[i].scene.zoom_to_window(lo - pad, hi + pad); + } + + fn open_count_target(&mut self, i: usize, name: String, key: CountKey) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + let instances = mode.instances(&self.tabs[i].scene.document); + let reference = instances + .iter() + .find(|inst| inst.duplicate_of.is_none() && inst.name.eq_ignore_ascii_case(&name) && key.matches(inst)) + .map(|inst| inst.handle); + mode.target = Some(CountTarget::Block { + name, + reference, + matching: [key.layer.is_some(), key.scale.is_some(), key.mirror_state.is_some()], + picked: false, + }); + mode.cursor = None; + self.tabs[i].count = Some(mode); + self.apply_count_display(i); + } + + /// The Field dialog's Show Count Instances: count mode on what the + /// expression counts. + pub(in crate::app) fn show_count_instances(&mut self, i: usize, json: &str) { + match codec::count::parse_query(json) { + Some(codec::count::CountQuery::Block { name, key, boundary }) => { + if let Some(b) = boundary { + if let Some(ring) = codec::count::boundary_polygon(&self.tabs[i].scene.document, b) { + let mut mode = self.tabs[i].count.take().unwrap_or_default(); + if mode.boundary.map(|(h, _)| h) != Some(b) { + self.drop_count_boundary(i, mode.boundary); + mode.boundary = Some((b, false)); + } + mode.area = Some(ring); + self.tabs[i].count = Some(mode); + } + } + self.open_count_target(i, name, key); + } + Some(codec::count::CountQuery::Single(handles)) if !handles.is_empty() => { + let hexes: Vec = handles.iter().map(|h| hex(*h)).collect(); + let area = if self.tabs[i].count.is_some() { "K" } else { "E" }; + self.count_run(i, &format!("{area} T {}", hexes.join(","))); + } + _ => {} + } + } + + pub(in crate::app) fn on_count(&mut self, m: CountMsg) -> Task { + let i = self.active_tab; + if self.tabs[i].is_start { + return Task::none(); + } + match m { + CountMsg::Search(s) => self.count_palette.search = s, + CountMsg::Sort(by_count) => { + if self.count_palette.by_count == by_count { + self.count_palette.descending = !self.count_palette.descending; + } else { + self.count_palette.by_count = by_count; + self.count_palette.descending = false; + } + } + CountMsg::Open(name, key) | CountMsg::Menu(name, key, RowAction::Review) => { + if self.count_palette.table.is_none() { + self.open_count_target(i, name, key); + } + } + CountMsg::Menu(name, key, RowAction::Field) => { + let code = self.block_field_code(i, &name, &key); + return self.start_count_field(i, code); + } + CountMsg::Menu(name, _, RowAction::Expand(k)) => { + let upper = name.to_ascii_uppercase(); + let list = &mut self.count_palette.expanded; + match list.iter_mut().find(|(n, _)| *n == upper) { + Some((_, e)) => e[k] = !e[k], + None => { + let mut e = [false; 3]; + e[k] = true; + list.push((upper, e)); + } + } + } + CountMsg::CreateTable => self.count_palette.table = Some(Vec::new()), + CountMsg::TableCheck(name, on) => { + if let Some(checked) = self.count_palette.table.as_mut() { + let upper = name.to_ascii_uppercase(); + checked.retain(|n| *n != upper); + if on { + checked.push(upper); + } + } + } + CountMsg::TableAll(on) => { + let names: Vec = if on { + let area = self.tabs[i].count.as_ref().and_then(|m| m.area.clone()); + let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref()); + crate::ui::window::count_palette::rows(&instances, &self.count_palette) + .into_iter() + .map(|r| r.name.to_ascii_uppercase()) + .collect() + } else { + Vec::new() + }; + self.count_palette.table = Some(names); + } + CountMsg::TableCancel => self.count_palette.table = None, + CountMsg::TableInsert => { + let names = self.count_palette.table.take().unwrap_or_default(); + if names.is_empty() { + return Task::none(); + } + return self.start_count_command(i, Box::new(CountTableCommand::placing(names)), "COUNTTABLE"); + } + CountMsg::Back | CountMsg::Close => self.close_count(i), + CountMsg::Match(k, on) => { + if let Some(CountMode { target: Some(CountTarget::Block { matching, .. }), .. }) = self.tabs[i].count.as_mut() { + matching[k] = on; + } + self.apply_count_display(i); + } + CountMsg::ToggleDetails => self.count_palette.details_closed = !self.count_palette.details_closed, + CountMsg::ToggleErrors => self.count_palette.errors_closed = !self.count_palette.errors_closed, + CountMsg::ShowError(h) => self.zoom_to_insert(i, h), + CountMsg::Prev | CountMsg::Next => { + let Some(mode) = self.tabs[i].count.as_ref() else { return Task::none() }; + let counted = mode.result(&self.tabs[i].scene.document).counted; + if counted.is_empty() { + return Task::none(); + } + let n = counted.len(); + let next = match (mode.cursor, matches!(m, CountMsg::Next)) { + (None, true) => 0, + (None, false) => n - 1, + (Some(c), true) => (c + 1) % n, + (Some(c), false) => (c + n - 1) % n, + }; + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.cursor = Some(next); + } + self.zoom_to_insert(i, counted[next]); + } + CountMsg::Area => return self.dispatch_command("COUNTAREA"), + CountMsg::Select => { + self.tabs[i].scene.deselect_all(); + let command = CountCommand::with_area(if self.tabs[i].count.is_some() { "K" } else { "C" }); + return self.start_count_command(i, Box::new(command), "COUNT"); + } + CountMsg::Field => return self.dispatch_command("COUNTFIELD"), + } + Task::none() + } +} diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 5c084fc78..123e23582 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -155,6 +155,15 @@ impl OpenCADStudio { ); } FieldDialogMsg::PlotScale(k) => state.plot_scale = k, + FieldDialogMsg::CountExpression(v) => state.count_expression = v, + FieldDialogMsg::ShowCountInstances => { + // The dialog closes and count mode shows what the expression counts. + let json = state.count_expression.clone(); + self.field_dialog = None; + self.close_active_modal(); + self.show_count_instances(i, &json); + return Task::none(); + } FieldDialogMsg::SelectObject => { // The dialog waits while one object is picked. self.active_modal = None; diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index e13a454d4..dda3f01b1 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1216,45 +1216,6 @@ impl OpenCADStudio { } } - // ── COUNT — entity statistics ───────────────────────────────────── - "COUNT" => { - use crate::command::KeywordCommand; - let c = KeywordCommand::new( - "COUNT", - "COUNT tally [All (by type) / by Layer]:", - vec![("All", "TYPE", None), ("By layer", "LAYER", None)], - ); - self.command_line.push_info(&c.prompt()); - self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); - } - cmd if cmd.starts_with("COUNT ") => { - let filter = cmd.split_once(' ').map(|(_, r)| r.trim().to_uppercase()); - let mut counts: std::collections::BTreeMap = Default::default(); - for e in self.tabs[i].scene.document.entities() { - let layer = &e.common().layer; - let type_name = crate::entities::names::dxf_name(e); - let key = match &filter { - Some(f) if f == "LAYER" => layer.clone(), - Some(f) if f == "TYPE" => type_name.to_string(), - Some(f) => { - // Filter by layer name - if layer.to_uppercase() != *f { - continue; - } - type_name.to_string() - } - None => type_name.to_string(), - }; - *counts.entry(key).or_default() += 1; - } - let total: usize = counts.values().sum(); - for (k, n) in &counts { - self.command_line.push_output(crate::tf!(" {k}: {n}").as_ref()); - } - self.command_line - .push_output(crate::tf!("COUNT: {total} entity(ies) total.").as_ref()); - } - "DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => { self.open_data_extraction(); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 7b9f22f2b..558a87546 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; mod attdef; mod field; +mod count; mod blocks; mod xref_attach; mod dim; @@ -296,6 +297,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_field(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_count(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_draw(cmd, i) { return Some(t); } diff --git a/src/app/document.rs b/src/app/document.rs index 9e147db7e..605f13d8e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -221,6 +221,8 @@ pub(super) struct DocumentTab { pub(super) paper_bg_color: Option<[f32; 4]>, /// Active REFEDIT session, if any. pub(super) refedit_session: Option, + /// COUNT mode, while it is on. + pub(crate) count: Option, /// Open BEDIT block tabs. Definitions are edited live; each tab owns its /// entry snapshot and camera so nested blocks can remain open independently. pub(super) block_edits: Vec, @@ -651,6 +653,7 @@ impl DocumentTab { bg_color: None, paper_bg_color: None, refedit_session: None, + count: None, block_edits: Vec::new(), active_block_edit: None, active_mleader_style: "Standard".to_string(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 392e1bed1..03f541acd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -816,6 +816,7 @@ pub(super) struct OpenCADStudio { pub(crate) show_browser: bool, /// Point Cloud Manager palette (POINTCLOUDMANAGER). pub(crate) pc_manager: crate::ui::window::pc_manager::PcManager, + pub(crate) count_palette: crate::ui::window::count_palette::CountPalette, /// Which viewport background the colour wheel is editing, or `None` when /// it is closed. One slot, because only one wheel can be open at a time. pub(crate) bg_picker: Option, @@ -3897,6 +3898,7 @@ pub enum Message { FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg), /// A click or search in the Point Cloud Manager. PcManager(crate::ui::window::pc_manager::PcManagerMsg), + Count(crate::ui::window::count_palette::CountMsg), PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), /// Result of the PDFATTACH file picker. PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), @@ -4222,6 +4224,7 @@ impl OpenCADStudio { show_external_references: false, show_browser: false, pc_manager: Default::default(), + count_palette: Default::default(), bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 690114e91..ee18517f7 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -612,6 +612,15 @@ pub struct UserSettings { /// BLOCKNAVIGATE (default "."). #[serde(default = "default_block_navigate")] pub block_navigate: String, + /// COUNTCOLOR (default 3). + #[serde(default = "default_count_color")] + pub count_color: i16, + /// COUNTERRORCOLOR (default 1). + #[serde(default = "default_count_error_color")] + pub count_error_color: i16, + /// COUNTSERVICE (default on). + #[serde(default = "default_true")] + pub count_service: bool, } /// A block the Blocks palette lists from outside the current drawing (a @@ -675,6 +684,14 @@ fn default_one_u8() -> u8 { 1 } +fn default_count_color() -> i16 { + 3 +} + +fn default_count_error_color() -> i16 { + 1 +} + fn default_block_navigate() -> String { ".".to_string() } @@ -851,6 +868,9 @@ impl Default for UserSettings { block_mru_list: 50, block_redefine_mode: 1, block_navigate: ".".to_string(), + count_color: 3, + count_error_color: 1, + count_service: true, } } } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 943ab3d4a..1f3d6ee60 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -606,6 +606,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::PointCloudManager => { self.pc_manager.show = false; } + PanelId::Count => { + self.count_palette.show = false; + self.ribbon.set_count_palette(false); + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -711,6 +715,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::Browser => self.show_browser, PanelId::NodeGraph => self.show_node_graph, PanelId::PointCloudManager => self.pc_manager.show, + PanelId::Count => self.count_palette.show, } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 2edd18786..91d8298db 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -744,6 +744,9 @@ impl OpenCADStudio { block_mru_list: self.block_mru_list, block_redefine_mode: self.block_redefine_mode, block_navigate: self.block_navigate.clone(), + count_color: self.count_palette.color, + count_error_color: self.count_palette.error_color, + count_service: self.count_palette.service, } } @@ -874,6 +877,9 @@ impl OpenCADStudio { self.block_mru_list = s.block_mru_list.min(100); self.block_redefine_mode = s.block_redefine_mode.min(2); self.block_navigate = s.block_navigate.clone(); + self.count_palette.color = s.count_color; + self.count_palette.error_color = s.count_error_color; + self.count_palette.service = s.count_service; self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); @@ -5520,7 +5526,7 @@ impl OpenCADStudio { /// Current visible rectangle in the active space. The result is deliberately /// not clamped to the paper sheet: Display and Window may include the grey /// canvas outside the sheet, matching Model-space plotting. - fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> { + pub(in crate::app) fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> { let scene = &self.tabs[self.active_tab].scene; let (canvas_w, canvas_h) = scene.selection.borrow().vp_size; let viewport = if scene.current_layout == "Model" { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index fdcac3bea..c0bbd3c11 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -493,6 +493,8 @@ impl OpenCADStudio { // The Reference Manager watches the active drawing and re-scans when // the palette is open on another tab's entries. self.refresh_xref_manager_if_stale(); + // Count mode recolours its references after the drawing changes. + self.refresh_count_if_stale(); // Let V4 plugins observe selection changes that happened while handling // this message (picking, window select, QSELECT, SELECTALL, grip edits, // and plugin request draining). @@ -1819,6 +1821,7 @@ impl OpenCADStudio { Message::AttdefDialog(message) => self.on_attdef_dialog(message), Message::FieldDialog(message) => self.on_field_dialog(message), Message::PcManager(message) => self.update_pc_manager(message), + Message::Count(message) => self.on_count(message), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index ee8e0d7fc..0c2db547f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1669,6 +1669,13 @@ bg={bg_ms:.1}ms n={view_count}" } } + // Count mode: a blue frame round the drawing and the count toolbar. + if let (Some(mode), false) = (tab.count.as_ref(), tab.is_start) { + viewport_stack = viewport_stack.push(crate::ui::window::count_palette::border()); + viewport_stack = viewport_stack + .push(crate::ui::window::count_palette::toolbar(mode, &tab.scene.document)); + } + // Reserve the overlaid command line when placing cursor-anchored panels. mark("chrome"); let command_line_inset = if self.command_line.history_open { @@ -1888,6 +1895,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::Browser => self.show_browser, crate::ui::dock::PanelId::NodeGraph => self.show_node_graph, crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show, + crate::ui::dock::PanelId::Count => self.count_palette.show, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -3023,6 +3031,13 @@ impl OpenCADStudio { auto_collapse, ), crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), + crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( + &self.count_palette, + tab.count.as_ref(), + &tab.scene.document, + width, + auto_collapse, + ), crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( &self.pc_manager, &tab.scene.document, diff --git a/src/modules/annotate/count_cmd.rs b/src/modules/annotate/count_cmd.rs new file mode 100644 index 000000000..8e75a99ab --- /dev/null +++ b/src/modules/annotate/count_cmd.rs @@ -0,0 +1,395 @@ +// COUNT / COUNTAREA, COUNTTABLE and UPDATEFIELD. +// +// Specify first corner point of the count area or +// [Current area/Entire model space/Object/Polygonal] : +// Specify opposite corner: (Polygonal: Specify start point: / +// Specify next point:; Object: +// Select object as count area boundary:) +// Select target objects or [List all blocks] : (COUNT) +// +// The command gathers its answers and hands them to the application as +// `_COUNTRUN L|T ` (COUNTAREA: `_COUNTAREASET `), the +// area being `C` (current view), `E` (entire model space), `R x,y x,y`, +// `P x,y x,y …` or `O `. + +use codec::types::Handle; +use codec::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Step { + Area, + Corner, + PolyStart, + PolyNext, + Object, + Targets, +} + +pub struct CountCommand { + /// COUNTAREA: only the area is asked. + area_only: bool, + step: Step, + area: String, + points: Vec, + targets: Vec, + picked: Option, +} + +fn xy(p: DVec3) -> String { + format!("{:?},{:?}", p.x, p.y) +} + +impl CountCommand { + pub fn new(area_only: bool) -> Self { + Self { + area_only, + step: Step::Area, + area: String::new(), + points: Vec::new(), + targets: Vec::new(), + picked: None, + } + } + + /// COUNT whose area is already answered (`COUNT _C`). + pub fn with_area(area: &str) -> Self { + Self { area: area.to_string(), step: Step::Targets, ..Self::new(false) } + } + + fn area_done(&mut self, area: String) -> CmdResult { + self.area = area; + if self.area_only { + return CmdResult::Dispatch(format!("_COUNTAREASET {}", self.area)); + } + self.step = Step::Targets; + CmdResult::NeedPoint + } + + fn area_keyword(&mut self, text: &str) -> CmdResult { + match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() { + "" | "C" | "CURRENT" | "CURRENT AREA" => self.area_done("C".into()), + "E" | "ENTIRE" | "ENTIRE MODEL SPACE" => self.area_done("E".into()), + "O" | "OBJECT" => { + self.step = Step::Object; + CmdResult::NeedPoint + } + "P" | "POLYGONAL" => { + self.points.clear(); + self.step = Step::PolyStart; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".into()), + } + } + + fn finish_targets(&self) -> CmdResult { + if self.targets.is_empty() { + return CmdResult::Dispatch(format!("_COUNTRUN {} L", self.area)); + } + let handles: Vec = self.targets.iter().map(|h| format!("{:X}", h.value())).collect(); + CmdResult::Dispatch(format!("_COUNTRUN {} T {}", self.area, handles.join(","))) + } +} + +impl CadCommand for CountCommand { + fn name(&self) -> &'static str { + if self.area_only { + "COUNTAREA" + } else { + "COUNT" + } + } + + fn prompt(&self) -> String { + match self.step { + Step::Area => "Specify first corner point of the count area or [Current area/Entire model space/Object/Polygonal] :".into(), + Step::Corner => "Specify opposite corner:".into(), + Step::PolyStart => "Specify start point:".into(), + Step::PolyNext => "Specify next point:".into(), + Step::Object => "Select object as count area boundary:".into(), + Step::Targets => "Select target objects or [List all blocks] :".into(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Area => vec![ + CmdOption::new("Current area", "C"), + CmdOption::new("Entire model space", "E"), + CmdOption::new("Object", "O"), + CmdOption::new("Polygonal", "P"), + ], + Step::Targets => vec![CmdOption::new("List all blocks", "L")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Targets => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::Area + } + + fn is_selection_gathering(&self) -> bool { + self.step == Step::Targets + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.targets = handles; + CmdResult::ReportMeasurement(format!("{} found", self.targets.len())) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Object + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + match self.picked.take() { + Some(EntityType::LwPolyline(pl)) if pl.is_closed && pl.vertices.len() >= 3 => { + self.area_done(format!("O {:X}", handle.value())) + } + // Anything else: the prompt again. + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::Area => { + self.points = vec![pt]; + self.step = Step::Corner; + CmdResult::NeedPoint + } + Step::Corner => { + let area = format!("R {} {}", xy(self.points[0]), xy(pt)); + self.area_done(area) + } + Step::PolyStart | Step::PolyNext => { + self.points.push(pt); + self.step = Step::PolyNext; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + match self.step { + Step::Area => Some(self.area_keyword(text)), + Step::Targets => match text.trim().trim_start_matches('_').to_ascii_uppercase().as_str() { + "L" | "LIST" | "LIST ALL BLOCKS" => { + self.targets.clear(); + Some(self.finish_targets()) + } + _ => None, + }, + _ => None, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Area => self.area_keyword(""), + Step::PolyNext if self.points.len() >= 3 => { + let ring: Vec = self.points.iter().map(|p| xy(*p)).collect(); + let area = format!("P {}", ring.join(" ")); + self.area_done(area) + } + Step::Targets => self.finish_targets(), + _ => CmdResult::NeedPoint, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let ring: Vec = match self.step { + Step::Corner => { + let a = self.points[0]; + vec![a, DVec3::new(pt.x, a.y, a.z), pt, DVec3::new(a.x, pt.y, a.z)] + } + Step::PolyNext => { + let mut ring = self.points.clone(); + ring.push(pt); + ring + } + _ => return None, + }; + let mut points: Vec<[f64; 3]> = ring.iter().map(|p| p.to_array()).collect(); + points.push(points[0]); + Some(WireModel::solid_f64("count_area".into(), points, WireModel::CYAN, false)) + } +} + +/// COUNTTABLE: the blocks to list, then where the table goes. +pub struct CountTableCommand { + step: TableStep, + /// Defined user blocks (sorted), and the listing's other counts: + /// external references, dependent and unnamed blocks. + blocks: Vec, + others: [usize; 3], + names: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TableStep { + Names, + List, + Point, +} + +impl CountTableCommand { + pub fn new(blocks: Vec, others: [usize; 3]) -> Self { + Self { step: TableStep::Names, blocks, others, names: Vec::new() } + } + + /// The palette's Create Table: the blocks are chosen, only the point is asked. + pub fn placing(names: Vec) -> Self { + Self { step: TableStep::Point, blocks: Vec::new(), others: [0; 3], names } + } + + /// `?`: the defined blocks matching the pattern and the summary. + fn listing(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let mut out = String::from("Defined blocks."); + for name in &self.blocks { + if pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) { + out.push_str(&format!("\n \"{name}\"")); + } + } + out.push_str(&format!( + "\n\nUser External Dependent Unnamed\nBlocks References Blocks Blocks\n{:>5}{:>11}{:>13}{:>12}", + self.blocks.len(), + self.others[0], + self.others[1], + self.others[2] + )); + out + } +} + +impl CadCommand for CountTableCommand { + fn name(&self) -> &'static str { + "COUNTTABLE" + } + + fn prompt(&self) -> String { + match self.step { + TableStep::Names => "Enter block name(s) to include or [?] :".into(), + TableStep::List => "Enter block(s) to list <*>:".into(), + TableStep::Point => "Specify insertion point:".into(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + TableStep::Point => InputKind::Point, + _ => InputKind::FreeText, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + Some(match self.step { + TableStep::Names if text == "?" => { + self.step = TableStep::List; + CmdResult::NeedPoint + } + TableStep::Names if text.is_empty() => { + self.names.clear(); + self.step = TableStep::Point; + CmdResult::NeedPoint + } + TableStep::Names => { + let mut names = Vec::new(); + for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match self.blocks.iter().find(|b| b.eq_ignore_ascii_case(part)) { + Some(b) => names.push(b.clone()), + None => return Some(CmdResult::CancelWithMessage(format!("Block \"{part}\" cannot be found."))), + } + } + self.names = names; + self.step = TableStep::Point; + CmdResult::NeedPoint + } + TableStep::List => CmdResult::Measurement(self.listing(text)), + TableStep::Point => return None, + }) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.step != TableStep::Point { + return CmdResult::NeedPoint; + } + CmdResult::Dispatch(format!("_COUNTTABLEPLACE {:?},{:?},{:?} {}", pt.x, pt.y, pt.z, self.names.join("|"))) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + TableStep::Point => CmdResult::ReportError("Invalid point.".into()), + _ => self.on_text_input("").unwrap_or(CmdResult::NeedPoint), + } + } +} + +/// UPDATEFIELD: `Select objects:`, then the fields of the selection are +/// evaluated again. +pub struct UpdateFieldCommand { + selected: Vec, +} + +impl UpdateFieldCommand { + pub fn new() -> Self { + Self { selected: Vec::new() } + } +} + +impl CadCommand for UpdateFieldCommand { + fn name(&self) -> &'static str { + "UPDATEFIELD" + } + + fn prompt(&self) -> String { + "Select objects:".into() + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.selected = handles; + CmdResult::NeedPoint + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.selected.is_empty() { + return CmdResult::Cancel; + } + let handles: Vec = self.selected.iter().map(|h| format!("{:X}", h.value())).collect(); + CmdResult::Dispatch(format!("_UPDATEFIELDRUN {}", handles.join(","))) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["COUNT", "COUNTAREA", "COUNTCLOSE", "COUNTFIELD", "COUNTLIST", "COUNTLISTCLOSE", "COUNTTABLE", "UPDATEFIELD"] +}); diff --git a/src/modules/annotate/field_cmd.rs b/src/modules/annotate/field_cmd.rs index 53e62dfa4..f6cccfb9c 100644 --- a/src/modules/annotate/field_cmd.rs +++ b/src/modules/annotate/field_cmd.rs @@ -29,6 +29,7 @@ pub struct FieldPlaceCommand { height: f64, attachment: AttachmentPoint, plane: WorkingPlane, + name: &'static str, } impl FieldPlaceCommand { @@ -41,8 +42,16 @@ impl FieldPlaceCommand { height, attachment: AttachmentPoint::TopLeft, plane: WorkingPlane::default(), + name: "FIELD", } } + + /// The command name shown with the prompts (COUNTFIELD places its field + /// as MTEXT). + pub fn named(mut self, name: &'static str) -> Self { + self.name = name; + self + } } fn short(v: f64) -> String { @@ -52,7 +61,7 @@ fn short(v: f64) -> String { impl CadCommand for FieldPlaceCommand { fn name(&self) -> &'static str { - "FIELD" + self.name } fn set_working_plane(&mut self, plane: WorkingPlane) { diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 5a882d148..8e9f8ba39 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -8,6 +8,7 @@ pub mod data_extract; pub mod data_link; pub mod ddedit; pub mod field_cmd; +pub mod count_cmd; pub mod diameter_dim; pub mod dim_baseline; pub mod dim_continue; diff --git a/src/modules/view/count_palette.rs b/src/modules/view/count_palette.rs new file mode 100644 index 000000000..97321536f --- /dev/null +++ b/src/modules/view/count_palette.rs @@ -0,0 +1,11 @@ +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_extract.svg")); +/// View › Palettes › Count: opens or closes the Count palette. +pub fn tool() -> ToolDef { + ToolDef { + id: "COUNTLIST", + label: "Count", + icon: ICON, + event: ModuleEvent::Command("_COUNTLISTTOGGLE".to_string()), + } +} diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs index d29f1826d..e11bbd383 100644 --- a/src/modules/view/mod.rs +++ b/src/modules/view/mod.rs @@ -12,6 +12,7 @@ pub mod plot_window; pub mod quick_print; mod properties_palette; mod sheetset; +mod count_palette; mod tile_horiz; mod tile_vert; pub mod visual_style; @@ -120,6 +121,7 @@ impl CadModule for ViewModule { tools: vec![ RibbonItem::LargeTool(tool_palettes::tool()), RibbonItem::LargeTool(properties_palette::tool()), + RibbonItem::LargeTool(count_palette::tool()), RibbonItem::LargeTool(sheetset::tool()), ], }, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a08c183b8..09a4dc793 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1930,6 +1930,15 @@ pub enum ViewportRefreshScope { /// Entity membership in document order, keyed by geometry epoch. type BlockMembers = (u64, HashMap>); +/// What count mode colours: see [`Scene::count_display`]. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CountDisplay { + pub counted: HashSet, + pub errors: HashSet, + pub color: [f32; 4], + pub error_color: [f32; 4], +} + pub struct Scene { pub camera: Rc>, /// View saved immediately before the latest navigation operation. ZOOM @@ -2018,6 +2027,10 @@ pub struct Scene { /// edited geometry stands out while the surrounding drawing stays visible /// for context. `None` = not editing. (#136) pub refedit_keep: Option>, + /// COUNT mode colours: counted references in COUNTCOLOR, overlapping + /// duplicates in COUNTERRORCOLOR, everything else faded. `None` outside + /// count mode. + pub count_display: Option, /// 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, @@ -2612,6 +2625,7 @@ impl Scene { preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, + count_display: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), constraint_hover_refs: Vec::new(), @@ -4084,10 +4098,32 @@ impl Scene { self.bump_geometry_no_blocks(); } + /// Enter / leave the COUNT colouring; a no-op when nothing changed. + pub fn set_count_display(&mut self, display: Option) { + if self.count_display == display { + return; + } + self.count_display = display; + self.bump_geometry_no_blocks(); + } + /// Fade the colours of wires that belong to entities outside the REFEDIT /// keep set (no-op when not editing). The geometry is untouched, so - /// hit-testing still works on faded entities. + /// hit-testing still works on faded entities. In count mode the counted + /// references and the duplicates take the count colours instead. fn apply_refedit_fade(&self, wires: &mut [WireModel], bg: [f32; 4]) { + if let Some(count) = &self.count_display { + for w in wires.iter_mut() { + let h = Self::handle_from_wire_name(&w.name); + w.color = match h { + Some(h) if count.errors.contains(&h) => count.error_color, + Some(h) if count.counted.contains(&h) => count.color, + _ => crate::scene::cache::block_cache::fade_toward_bg(w.color, bg), + }; + // Shared instance geometry keeps its own colour; draw these wires on their own. + w.render_instance = None; + } + } let Some(keep) = &self.refedit_keep else { return; }; diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 1e62a58cf..9cab6440c 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -48,6 +48,8 @@ pub enum PanelId { NodeGraph, /// Regions and scans of the drawing's point clouds. PointCloudManager, + /// Block counts (COUNTLIST). + Count, } impl PanelId { @@ -60,6 +62,7 @@ impl PanelId { PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", PanelId::PointCloudManager => "Point Cloud Manager", + PanelId::Count => "Count", } } @@ -72,6 +75,7 @@ impl PanelId { PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, PanelId::PointCloudManager => 280.0, + PanelId::Count => 280.0, } } @@ -162,6 +166,7 @@ impl DockState { PanelId::Browser, PanelId::NodeGraph, PanelId::PointCloudManager, + PanelId::Count, ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs index 4c68f01ae..407aa66c2 100644 --- a/src/ui/popup/context_menu.rs +++ b/src/ui/popup/context_menu.rs @@ -722,6 +722,12 @@ fn idle_rows( t!("Quick Select...").into_owned(), MenuAction::QuickSelect, ))); + // Count the selected objects, or start a count. + rows.push(MenuRow::Item(if has_selection { + cmd(t!("Count Selection").into_owned(), "COUNT _C") + } else { + cmd(t!("Count menu").into_owned(), "COUNT") + })); rows.push(MenuRow::Separator); // ── Panels ───────────────────────────────────────────────────────── diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 20989bd37..d59bac1c6 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -57,6 +57,8 @@ pub struct Ribbon { pub show_ucs_icon: bool, /// Properties panel (PROPERTIES) visibility — drives the Properties button highlight. pub show_properties: bool, + /// The Count palette is open (View › Palettes › Count). + pub show_count_palette: bool, /// File tabs (FILETAB) visibility — drives the File Tabs button highlight. pub show_file_tabs: bool, /// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight. @@ -173,6 +175,7 @@ impl Ribbon { show_viewcube: true, show_ucs_icon: true, show_properties: true, + show_count_palette: false, show_file_tabs: true, show_layout_tabs: true, open_dropdown: None, @@ -330,6 +333,9 @@ impl Ribbon { pub fn set_properties(&mut self, on: bool) { self.show_properties = on; } + pub fn set_count_palette(&mut self, on: bool) { + self.show_count_palette = on; + } pub fn set_file_tabs(&mut self, on: bool) { self.show_file_tabs = on; } @@ -347,6 +353,7 @@ impl Ribbon { show_viewcube: self.show_viewcube, show_ucs_icon: self.show_ucs_icon, show_properties: self.show_properties, + show_count_palette: self.show_count_palette, show_block_palette, show_file_tabs: self.show_file_tabs, show_layout_tabs: self.show_layout_tabs, diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 74e7d9393..89bb1de2f 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -37,6 +37,7 @@ pub(super) struct ToggleState { pub show_viewcube: bool, pub show_ucs_icon: bool, pub show_properties: bool, + pub show_count_palette: bool, pub show_block_palette: bool, pub show_file_tabs: bool, pub show_layout_tabs: bool, @@ -379,6 +380,7 @@ pub(super) fn is_active_tool( "NAVVCUBE" => state.show_viewcube, "UCSICON" => state.show_ucs_icon, "PROPERTIES" => state.show_properties, + "COUNTLIST" => state.show_count_palette, "BLOCKPALETTE" => state.show_block_palette, "FILETAB" => state.show_file_tabs, "LAYOUTTAB" => state.show_layout_tabs, diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs new file mode 100644 index 000000000..cfd74c2bb --- /dev/null +++ b/src/ui/window/count_palette.rs @@ -0,0 +1,565 @@ +//! The Count palette (COUNTLIST) and count mode (COUNT): the drawing's +//! blocks with how many references of each are counted, and — once a block +//! is chosen — its counted references coloured in the drawing with a +//! floating toolbar above it. Counting itself is `codec::count`. + +use crate::app::Message; +use crate::ui::dock::PanelId; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use codec::count::{BlockInstance, CountKey}; +use codec::{CadDocument, Handle}; +use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +const BLOCK_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/block.svg"); +const AREA_ICON: &[u8] = include_bytes!("../../../assets/icons/shapes/rect.svg"); +const SELECT_ICON: &[u8] = include_bytes!("../../../assets/icons/blocks/select_objects.svg"); +const TABLE_ICON: &[u8] = include_bytes!("../../../assets/icons/table.svg"); +const WARNING_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); +const CHECK_ICON: &[u8] = include_bytes!("../../../assets/icons/ui/check.svg"); + +/// The name of the layer count areas are drawn on. +pub const AREA_LAYER: &str = "0-CountArea"; + +/// What a count counts. +#[derive(Debug, Clone, PartialEq)] +pub enum CountTarget { + /// The references of a block. `reference` is the instance the match + /// options compare with; `picked` when it came from a COUNT selection. + Block { + name: String, + reference: Option, + matching: [bool; 3], + picked: bool, + }, + /// Several picked objects counted as one group. + Group(Vec), +} + +/// Count mode of a drawing. +#[derive(Debug, Clone, Default)] +pub struct CountMode { + /// The count area (a closed WCS polygon); `None` = all of model space. + pub area: Option>, + /// The closed polyline marking the area, and whether count mode drew it + /// (then COUNTCLOSE erases it unless a field refers to it). + pub boundary: Option<(Handle, bool)>, + pub target: Option, + /// The counted reference ← / → last showed. + pub cursor: Option, + /// The scene geometry epoch the colouring was computed at. + pub epoch: u64, +} + +/// A count mode's result: the counted references and the overlapping +/// duplicates (errors). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CountResult { + pub counted: Vec, + pub errors: Vec, +} + +impl CountMode { + pub fn instances(&self, doc: &CadDocument) -> Vec { + codec::count::block_instances(doc, self.area.as_deref()) + } + + /// The key the match options build from the reference instance. + pub fn key(&self, instances: &[BlockInstance]) -> CountKey { + match &self.target { + Some(CountTarget::Block { reference: Some(r), matching, .. }) => instances + .iter() + .find(|i| i.handle == *r) + .map(|i| CountKey::of(i, matching[0], matching[1], matching[2])) + .unwrap_or_default(), + _ => CountKey::default(), + } + } + + pub fn result(&self, doc: &CadDocument) -> CountResult { + match &self.target { + None => CountResult::default(), + Some(CountTarget::Group(handles)) => CountResult { + counted: handles.iter().copied().filter(|h| doc.get_entity(*h).is_some()).collect(), + errors: Vec::new(), + }, + Some(CountTarget::Block { name, .. }) => { + let instances = self.instances(doc); + let key = self.key(&instances); + let mine = |i: &&BlockInstance| i.name.eq_ignore_ascii_case(name) && key.matches(i); + CountResult { + counted: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_none()).map(|i| i.handle).collect(), + errors: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_some()).map(|i| i.handle).collect(), + } + } + } + } + + /// The name the toolbar and the command line show for the target. + pub fn target_name(&self) -> Option { + match &self.target { + Some(CountTarget::Block { name, .. }) => Some(name.clone()), + Some(CountTarget::Group(_)) => Some("group".into()), + None => None, + } + } +} + +/// Palette state that is not in the drawing, and the profile variables +/// COUNTCOLOR, COUNTERRORCOLOR and COUNTSERVICE. +#[derive(Debug)] +pub struct CountPalette { + pub show: bool, + pub search: String, + /// Sorted by the Count column (else by Name), and descending. + pub by_count: bool, + pub descending: bool, + /// Expanded rows: block name (upper case) → expand by layer, scale, mirror state. + pub expanded: Vec<(String, [bool; 3])>, + /// Create Table selection mode: the checked block names (upper case). + pub table: Option>, + pub details_closed: bool, + pub errors_closed: bool, + pub color: i16, + pub error_color: i16, + pub service: bool, +} + +impl Default for CountPalette { + fn default() -> Self { + Self { + show: false, + search: String::new(), + by_count: false, + descending: false, + expanded: Vec::new(), + table: None, + details_closed: false, + errors_closed: false, + color: 3, + error_color: 1, + service: true, + } + } +} + +impl CountPalette { + pub fn expansion(&self, name: &str) -> Option<[bool; 3]> { + let key = name.to_ascii_uppercase(); + self.expanded.iter().find(|(n, _)| *n == key).map(|(_, e)| *e) + } +} + +/// What a row's context menu does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowAction { + Review, + Field, + /// Expand by layer (0), scale (1) or mirror state (2). + Expand(usize), +} + +#[derive(Debug, Clone)] +pub enum CountMsg { + Search(String), + /// Sort by the Count column (`true`) or the Name column. + Sort(bool), + /// A row clicked: count mode on its block (and key, for an expanded row). + Open(String, CountKey), + Menu(String, CountKey, RowAction), + CreateTable, + TableCheck(String, bool), + TableAll(bool), + TableCancel, + TableInsert, + Back, + Match(usize, bool), + ToggleDetails, + ToggleErrors, + /// Show an overlapping duplicate. + ShowError(Handle), + Area, + Prev, + Next, + Select, + Field, + Close, +} + +fn msg(m: CountMsg) -> Message { + Message::Count(m) +} + +/// One block's row: counted references and duplicates. +pub struct Row { + pub name: String, + pub count: usize, + pub errors: usize, +} + +/// The rows of the list: blocks whose name contains the search text +/// (case-insensitively), sorted by the chosen column. +pub fn rows(instances: &[BlockInstance], palette: &CountPalette) -> Vec { + let query = palette.search.trim().to_lowercase(); + let mut rows: Vec = codec::count::block_counts(instances) + .into_iter() + .filter(|c| query.is_empty() || c.name.to_lowercase().contains(&query)) + .map(|c| Row { count: c.counted.len(), errors: c.duplicates.len(), name: c.name }) + .collect(); + if palette.by_count { + rows.sort_by(|a, b| a.count.cmp(&b.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + } + if palette.descending { + rows.reverse(); + } + rows +} + +fn small_icon<'a>(bytes: &'static [u8], size: f32) -> Element<'a, Message> { + crate::ui::icons::semantic(bytes, size) +} + +fn row_menu(name: String, key: CountKey, expandable: bool) -> Element<'static, Message> { + let entry = |label: String, action: RowAction| -> Element<'static, Message> { + button(text(label).size(12)) + .width(Fill) + .padding([5, 12]) + .on_press(msg(CountMsg::Menu(name.clone(), key.clone(), action))) + .style(|theme: &Theme, status| { + let mut s = button::text(theme, status); + if matches!(status, button::Status::Hovered) { + s.background = Some(Background::Color(theme.palette().primary.base.color)); + s.text_color = theme.palette().primary.base.text; + } + s + }) + .into() + }; + let mut items = column![ + entry(crate::t!("Review Count Details").into_owned(), RowAction::Review), + entry(crate::t!("Insert Count Field").into_owned(), RowAction::Field), + ] + .spacing(1); + if expandable { + items = items + .push(container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + })) + .push(container(text(crate::t!("EXPAND BY")).size(10).style(muted_style)).padding([3, 12])) + .push(entry(crate::t!("Layer").into_owned(), RowAction::Expand(0))) + .push(entry(crate::t!("Scale").into_owned(), RowAction::Expand(1))) + .push(entry(crate::t!("Mirror State").into_owned(), RowAction::Expand(2))); + } + container(items) + .padding(4) + .width(Length::Fixed(200.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .into() +} + +fn list_row<'a>( + palette: &CountPalette, + name: &str, + key: CountKey, + label: String, + count: String, + errors: bool, + depth: u16, + expanded: Option, +) -> Element<'a, Message> { + let lead: Element<'a, Message> = if let Some(checked) = palette.table.as_ref().filter(|_| depth == 0) { + let on = checked.contains(&name.to_ascii_uppercase()); + let n = name.to_string(); + checkbox(on).size(14).on_toggle(move |v| msg(CountMsg::TableCheck(n.clone(), v))).into() + } else if expanded == Some(true) { + container(text("−").size(13)).width(Length::Fixed(14.0)).into() + } else { + Space::new().width(Length::Fixed(14.0)).into() + }; + let mut cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 18.0)), lead]; + if depth == 0 { + cells = cells.push(small_icon(BLOCK_ICON, 14.0)); + } + cells = cells.push(text(label).size(12).width(Fill)); + if errors { + cells = cells.push(crate::ui::icons::themed_warning(WARNING_ICON, 12.0)); + } + cells = cells.push(text(count).size(12)); + let body = container(cells.spacing(6).align_y(iced::Center)).width(Fill).padding([4, 6]); + let area = mouse_area(body) + .on_press(msg(CountMsg::Open(name.to_string(), key.clone()))) + .interaction(iced::mouse::Interaction::Pointer); + let (n, expandable) = (name.to_string(), depth == 0); + iced_aw::ContextMenu::new(area, move || row_menu(n.clone(), key.clone(), expandable)).into() +} + +fn header_button<'a>(label: String, active: bool, descending: bool, m: CountMsg) -> Element<'a, Message> { + let arrow = if !active { + "" + } else if descending { + " ▼" + } else { + " ▲" + }; + button(text(format!("{label}{arrow}")).size(11)) + .on_press(msg(m)) + .style(button::text) + .padding([2, 4]) + .into() +} + +/// The list: search, area button, Name / Count columns, rows, Create Table. +fn list_view<'a>(palette: &'a CountPalette, instances: &[BlockInstance]) -> Element<'a, Message> { + let search = text_input(&crate::t!("Search"), &palette.search) + .on_input(|v| msg(CountMsg::Search(v))) + .padding([4, 8]) + .size(12) + .style(field_style); + let top = row![ + search.width(Fill), + crate::ui::dock::tool_button( + small_icon(AREA_ICON, crate::ui::dock::TOOL_H), + crate::t!("Count in a specified area").into_owned(), + msg(CountMsg::Area), + ), + ] + .spacing(4) + .align_y(iced::Center); + let header = row![ + header_button(crate::t!("Name").into_owned(), !palette.by_count, palette.descending, CountMsg::Sort(false)), + Space::new().width(Fill), + header_button(crate::t!("Count column").into_owned(), palette.by_count, palette.descending, CountMsg::Sort(true)), + ] + .align_y(iced::Center); + let mut list = column![].spacing(1); + for r in rows(instances, palette) { + let expansion = palette.expansion(&r.name).filter(|e| e.iter().any(|v| *v)); + let count = if expansion.is_some() { "•••".to_string() } else { r.count.to_string() }; + list = list.push(list_row( + palette, + &r.name, + CountKey::default(), + r.name.clone(), + count, + r.errors > 0, + 0, + Some(expansion.is_some()), + )); + if let Some([l, s, m]) = expansion { + for (key, n) in codec::count::expanded_counts(instances, &r.name, l, s, m) { + let label = key.label(); + list = list.push(list_row(palette, &r.name, key, label, n.to_string(), false, 1, None)); + } + } + } + let footer: Element<'a, Message> = match &palette.table { + None => row![ + Space::new().width(Fill), + button(text(crate::t!("Create Table")).size(12)) + .on_press(msg(CountMsg::CreateTable)) + .style(button_style(false)) + .padding([5, 12]), + ] + .into(), + Some(checked) => { + let all = rows(instances, palette); + let every = !all.is_empty() && all.iter().all(|r| checked.contains(&r.name.to_ascii_uppercase())); + let mut insert = button(text(crate::t!("Insert")).size(12)).style(button_style(true)).padding([5, 12]); + if !checked.is_empty() { + insert = insert.on_press(msg(CountMsg::TableInsert)); + } + row![ + checkbox(every).size(14).on_toggle(|v| msg(CountMsg::TableAll(v))), + Space::new().width(Fill), + button(text(crate::t!("Cancel")).size(12)) + .on_press(msg(CountMsg::TableCancel)) + .style(button_style(false)) + .padding([5, 12]), + insert, + ] + .spacing(6) + .align_y(iced::Center) + .into() + } + }; + column![ + top, + container(column![header, scrollable(list).height(Fill)].spacing(2)) + .padding(4) + .height(Fill) + .style(card), + footer, + ] + .spacing(6) + .into() +} + +fn card(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + } +} + +/// Count mode in the palette: back to the list, the counted block with the +/// match options, and the error report. +fn mode_view<'a>(palette: &'a CountPalette, mode: &'a CountMode, doc: &CadDocument) -> Element<'a, Message> { + let back = button(row![crate::ui::icons::themed_arrow_left(10.0), text(crate::t!("Back to List")).size(12)].spacing(4).align_y(iced::Center)) + .on_press(msg(CountMsg::Back)) + .style(button::text) + .padding([2, 2]); + let result = mode.result(doc); + let mut body = column![back].spacing(8); + if let Some(name) = mode.target_name() { + let mut details = column![button( + row![ + crate::ui::icons::themed_success(CHECK_ICON, 12.0), + text(format!("{name}: {}", result.counted.len())).size(12).width(Fill), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(msg(CountMsg::ToggleDetails)) + .style(button::text) + .padding(0)] + .spacing(6); + if let (false, Some(CountTarget::Block { matching, .. })) = (palette.details_closed, &mode.target) { + for (k, label) in ["Match Layer", "Match Scale", "Match Mirror State"].iter().enumerate() { + details = details.push( + checkbox(matching[k]) + .label(crate::t!(*label).into_owned()) + .text_size(12) + .size(14) + .on_toggle(move |v| msg(CountMsg::Match(k, v))), + ); + } + } + body = body.push(container(details).padding(8).width(Fill).style(card)); + } + if !result.errors.is_empty() { + let mut report = column![button( + row![ + crate::ui::icons::themed_warning(WARNING_ICON, 12.0), + text(crate::tf!("Count Error Report: {}", result.errors.len())).size(12).width(Fill), + ] + .spacing(6) + .align_y(iced::Center), + ) + .on_press(msg(CountMsg::ToggleErrors)) + .style(button::text) + .padding(0)] + .spacing(4); + if !palette.errors_closed { + for h in &result.errors { + report = report.push( + button(text(crate::t!("Overlapping object")).size(12)) + .on_press(msg(CountMsg::ShowError(*h))) + .style(button::text) + .padding([2, 18]), + ); + } + } + body = body.push(container(report).padding(8).width(Fill).style(card)); + } + body.into() +} + +pub fn view<'a>( + palette: &'a CountPalette, + mode: Option<&'a CountMode>, + doc: &'a CadDocument, + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), auto_collapse); + // ponytail: the list is recounted on every view; cache by geometry epoch + // if very large drawings make the palette slow. + let body = match mode { + Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc), + _ => { + let instances = codec::count::block_instances(doc, mode.and_then(|m| m.area.as_deref())); + list_view(palette, &instances) + } + }; + crate::ui::dock::frame(column![title_bar, body].spacing(6), width) +} + +fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option) -> Element<'a, Message> { + let mut b = button(icon).style(button::text).padding([3, 5]); + if let Some(m) = m { + b = b.on_press(msg(m)); + } + tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into() +} + +/// The floating toolbar over the drawing in count mode: the count, the +/// status, previous / next reference, area, select, field and close. +pub fn toolbar<'a>(mode: &CountMode, doc: &CadDocument) -> Element<'a, Message> { + let has_target = mode.target.is_some(); + let result = mode.result(doc); + let count = if has_target { result.counted.len().to_string() } else { "-".to_string() }; + let status: Element<'a, Message> = if !result.errors.is_empty() { + crate::ui::icons::themed_warning(WARNING_ICON, 14.0) + } else if has_target { + container(text("i").size(11).style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) })) + .padding([0, 4]) + .style(|theme: &Theme| container::Style { + border: Border { color: theme.palette().primary.base.color, width: 1.0, radius: 8.0.into() }, + ..Default::default() + }) + .into() + } else { + crate::ui::icons::themed_disabled(WARNING_ICON, 14.0) + }; + let on = |m: CountMsg| has_target.then_some(m); + let icon = |bytes: &'static [u8]| -> Element<'a, Message> { + if has_target { + crate::ui::icons::themed_success(bytes, 16.0) + } else { + crate::ui::icons::themed_disabled(bytes, 16.0) + } + }; + let bar = row![ + text(crate::tf!("Count: {}", count)).size(13), + status, + bar_button(crate::ui::icons::themed_arrow_left(12.0), crate::t!("Previous").into_owned(), on(CountMsg::Prev)), + bar_button(crate::ui::icons::themed_arrow_right(12.0), crate::t!("Next").into_owned(), on(CountMsg::Next)), + bar_button(crate::ui::icons::themed_success(AREA_ICON, 16.0), crate::t!("Count in a specified area").into_owned(), Some(CountMsg::Area)), + bar_button(crate::ui::icons::themed_success(SELECT_ICON, 16.0), crate::t!("Select objects").into_owned(), Some(CountMsg::Select)), + bar_button(icon(TABLE_ICON), crate::t!("Insert Count Field").into_owned(), on(CountMsg::Field)), + bar_button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0), crate::t!("Close").into_owned(), Some(CountMsg::Close)), + ] + .spacing(6) + .align_y(iced::Center); + let bar = container(bar).padding([4, 10]).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { color: theme.palette().background.neutral.color, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }); + container(iced::widget::opaque(bar)).width(Fill).align_x(iced::Center).padding([10, 0]).into() +} + +/// The blue frame around the drawing area in count mode. +pub fn border<'a>() -> Element<'a, Message> { + container(Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill) + .style(|_: &Theme| container::Style { + border: Border { color: Color::from_rgb(0.18, 0.52, 0.95), width: 2.0, radius: 0.0.into() }, + ..Default::default() + }) + .into() +} + +/// A colour index as the display colour (`COUNTCOLOR` / `COUNTERRORCOLOR`). +pub fn aci_rgba(index: i16) -> [f32; 4] { + crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::from_index(index)) +} + diff --git a/src/ui/window/field_dialog.rs b/src/ui/window/field_dialog.rs index 0d76b6816..54dbece83 100644 --- a/src/ui/window/field_dialog.rs +++ b/src/ui/window/field_dialog.rs @@ -32,7 +32,7 @@ pub const CATEGORIES: &[(&str, &[&str])] = &[ &["Author", "Comments", "Filename", "Filesize", "HyperlinkBase", "Keywords", "LastSavedBy", "Subject", "Title"], ), ("Linked", &["Hyperlink"]), - ("Objects", &["Formula", "NamedObject", "Object"]), + ("Objects", &["Count", "CountInArea", "Formula", "NamedObject", "Object"]), ("Other", &["DieselExpression", "SystemVariable"]), ( "Plot", @@ -77,6 +77,8 @@ pub enum FieldKind { Formula, Hyperlink, PlotScale, + /// Count / CountInArea: a JSON expression. + Count, } /// Formula precisions: the current one, then 0 to 8 decimals. @@ -95,6 +97,7 @@ pub fn kind_of(name: &str) -> FieldKind { "Formula" => FieldKind::Formula, "Hyperlink" => FieldKind::Hyperlink, "PlotScale" => FieldKind::PlotScale, + "Count" | "CountInArea" => FieldKind::Count, _ => FieldKind::Text, } } @@ -128,6 +131,8 @@ pub struct FieldDialogState { pub hyperlink_url: String, /// Index into codec PLOT_SCALE_FORMATS. pub plot_scale: usize, + /// The JSON expression of a Count / CountInArea field. + pub count_expression: String, pub preview: String, /// Today in each of DATE_FORMATS, for the Examples list. pub examples: Vec, @@ -158,6 +163,7 @@ impl FieldDialogState { hyperlink_text: String::new(), hyperlink_url: String::new(), plot_scale: 0, + count_expression: String::new(), preview: String::new(), examples: Vec::new(), } @@ -211,6 +217,13 @@ impl FieldDialogState { .map_or_else(|| "\\AcVar PlotScale".to_string(), |f| f.1.to_string()), vec![], ), + FieldKind::Count => { + let evaluator = if self.name == "CountInArea" { "AcCount2" } else { "AcCount" }; + match self.count_expression.trim() { + "" => (format!("\\{evaluator}"), vec![]), + json => (format!("\\{evaluator} {json}"), vec![]), + } + } FieldKind::SystemVariable => (format!("\\AcVar {}", self.sysvar), vec![]), FieldKind::Diesel => (format!("\\AcDiesel {}", self.diesel), vec![]), FieldKind::NamedObject => match self.named.and_then(|i| self.named_names.get(i)) { @@ -278,6 +291,8 @@ pub enum FieldDialogMsg { HyperlinkUrl(String), BrowseHyperlink, PlotScale(usize), + CountExpression(String), + ShowCountInstances, Help, Ok, } @@ -542,6 +557,7 @@ fn format_panel<'a>(state: &'a FieldDialogState) -> Element<'a, Message> { ] .spacing(8) .into(), + FieldKind::Count => column![].into(), FieldKind::Object => column![ row![ text(t!("Object type")).size(12).width(Length::Fixed(96.0)), @@ -629,6 +645,33 @@ pub fn view<'a>( ] .spacing(10), FieldKind::Hyperlink => column![card(t!("Hyperlink").into_owned(), format_panel(state)), preview].spacing(10), + FieldKind::Count => column![ + card( + t!("Expression").into_owned(), + column![ + text_input("", &state.count_expression) + .size(12) + .padding([8, 8]) + .font(iced::Font::MONOSPACE) + .style(field_style) + .on_input(|v| msg(FieldDialogMsg::CountExpression(v))), + row![ + button(text(t!("Evaluate")).size(12)) + .on_press(msg(FieldDialogMsg::Evaluate)) + .style(button_style(false)) + .padding([5, 14]), + button(text(t!("Show Count Instances")).size(12)) + .on_press(msg(FieldDialogMsg::ShowCountInstances)) + .style(button_style(false)) + .padding([5, 14]), + ] + .spacing(8), + ] + .spacing(8), + ), + preview, + ] + .spacing(10), _ => column![card(t!("Format").into_owned(), format_panel(state)), preview].spacing(10), }; let (code, _) = state.code(); diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 3b3282d0f..3f3fafc09 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -27,6 +27,7 @@ pub mod alias_editor; pub mod find_replace; pub mod named_parameters; pub mod pc_manager; +pub mod count_palette; pub mod pdf_dialogs; pub mod xref_attach; pub mod xref_help; From f76b6cb3bb0d4b58c8d59e36761aefda93e28533 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:53:28 +0300 Subject: [PATCH 02/11] WIP count: automation, locales --- locales/ar-SA/opencadstudio.ftl | 17 ++++++++ locales/bg-BG/opencadstudio.ftl | 17 ++++++++ locales/cs-CZ/opencadstudio.ftl | 17 ++++++++ locales/de-DE/opencadstudio.ftl | 17 ++++++++ locales/el-GR/opencadstudio.ftl | 17 ++++++++ locales/en-US/opencadstudio.ftl | 17 ++++++++ locales/es-ES/opencadstudio.ftl | 17 ++++++++ locales/fi-FI/opencadstudio.ftl | 17 ++++++++ locales/fr-FR/opencadstudio.ftl | 17 ++++++++ locales/hi-IN/opencadstudio.ftl | 17 ++++++++ locales/hu-HU/opencadstudio.ftl | 17 ++++++++ locales/it-IT/opencadstudio.ftl | 17 ++++++++ locales/ja-JP/opencadstudio.ftl | 17 ++++++++ locales/ko-KR/opencadstudio.ftl | 17 ++++++++ locales/nl-NL/opencadstudio.ftl | 17 ++++++++ locales/pl-PL/opencadstudio.ftl | 17 ++++++++ locales/pt-BR/opencadstudio.ftl | 17 ++++++++ locales/ru-RU/opencadstudio.ftl | 17 ++++++++ locales/tr-TR/opencadstudio.ftl | 17 ++++++++ locales/zh-CN/opencadstudio.ftl | 17 ++++++++ locales/zh-TW/opencadstudio.ftl | 17 ++++++++ src/app/control/actions.rs | 69 +++++++++++++++++++++++++++++++ src/locale_catalog.rs | 16 +++++++ src/modules/view/count_palette.rs | 2 +- src/ui/window/count_palette.rs | 4 +- 25 files changed, 445 insertions(+), 3 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 97fec3890..1a8715e9d 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5930,3 +5930,20 @@ style-manager = .center-of-text-overline = مركز النص مع خط علوي .block-extents = نطاق الكتلة .base-point = نقطة الأساس +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 0fb1d43e9..814d0f4d4 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5918,3 +5918,20 @@ style-manager = .center-of-text-overline = Център на надчертан текст .block-extents = Обхват на блок .base-point = Базова точка +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index d17ecfb1d..8613591b1 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5915,3 +5915,20 @@ style-manager = .center-of-text-overline = Střed textu s přeškrtnutím .block-extents = Blokovat rozsahy .base-point = Základní bod +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 7fd9f0bd4..c05f97f7e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5916,3 +5916,20 @@ style-manager = .center-of-text-overline = Textzentrum mit Oberstrich .block-extents = Blockgrenzen .base-point = Basispunkt +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 53601f4be..d39f35443 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5918,3 +5918,20 @@ style-manager = .center-of-text-overline = Κέντρο του κειμένου με επιγράμμιση .block-extents = Όρια μπλοκ .base-point = Σημείο βάσης +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index dcbbb7b69..d6ba4c9c1 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5919,3 +5919,20 @@ style-manager = .center-of-text-overline = Center of text with overline .block-extents = Block extents .base-point = Base point +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index df1b19661..0ad5c4630 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5918,3 +5918,20 @@ style-manager = .center-of-text-overline = Centro del texto con suprarrayado .block-extents = Extensión del bloque .base-point = Punto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 99f18d514..7f43aa3fc 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5915,3 +5915,20 @@ style-manager = .center-of-text-overline = Tekstin keskikohta yliviivauksella .block-extents = Estä laajuudet .base-point = Peruspiste +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index bc00d9334..d634008d5 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5917,3 +5917,20 @@ style-manager = .center-of-text-overline = Centre du texte avec surlignement .block-extents = Limites du bloc .base-point = Point de base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 59440e35e..e0f26ae1e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5906,3 +5906,20 @@ style-manager = .center-of-text-overline = ऊपरी रेखा सहित टेक्स्ट का केंद्र .block-extents = ब्लॉक परिमिति .base-point = आधार बिंदु +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 2066c5156..9df1e87b6 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5917,3 +5917,20 @@ style-manager = .center-of-text-overline = Szöveg közepe áthúzással .block-extents = Kiterjedések blokkolása .base-point = Alappont +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index bab01a81c..01c52fbb7 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5917,3 +5917,20 @@ style-manager = .center-of-text-overline = Centro del testo con sopralineato .block-extents = Blocca estensioni .base-point = Punto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index f0df48710..88aaa76ca 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5906,3 +5906,20 @@ style-manager = .center-of-text-overline = 上線付きテキストの中心 .block-extents = ブロック範囲 .base-point = 基点 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 6ad109cc7..599abf44d 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5917,3 +5917,20 @@ style-manager = .center-of-text-overline = 윗줄이 있는 텍스트 중앙 .block-extents = 블록 범위 .base-point = 기준점 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 2f5e39e39..9694b0e11 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5916,3 +5916,20 @@ style-manager = .center-of-text-overline = Tekstmiddelpunt met bovenstreep .block-extents = Symboolgrenzen .base-point = Basispunt +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index c31421220..dad85cf06 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5917,3 +5917,20 @@ style-manager = .center-of-text-overline = Środek tekstu z podkreśleniem .block-extents = Zasięg bloku .base-point = Punkt bazowy +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index e96dea04d..820c37af5 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5918,3 +5918,20 @@ style-manager = .center-of-text-overline = Centro do texto com sobrelinha .block-extents = Extensões do bloco .base-point = Ponto base +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index e3eb50fa6..f6fdb4c9c 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5919,3 +5919,20 @@ style-manager = .center-of-text-overline = Центр текста с надчёркиванием .block-extents = Габариты блока .base-point = Базовая точка +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 3b01d7c2c..ab1501909 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5841,3 +5841,20 @@ style-manager = .center-of-text-overline = Üst çizgili metin merkezi .block-extents = Blok sınırları .base-point = Taban noktası +count-feature = + .palette = Sayım + .menu = Say + .selection = Seçimi say + .review = Sayım ayrıntılarını incele + .insert-field = Sayım alanı ekle + .expand-by = GENİŞLET + .mirror-state = Ayna durumu + .area = Belirli bir alanda say + .create-table = Tablo Oluştur + .back = Listeye dön + .match-scale = Ölçeği Eşleştir + .match-mirror = Ayna Durumunu Eşleştir + .error-report = Sayım hata raporu: __ocs_fmt_0__ + .toolbar-count = Sayım: __ocs_fmt_0__ + .overlap = Üst üste binen nesne + .show-instances = Örnekleri göster diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 290f64be0..770c2600c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5901,3 +5901,20 @@ style-manager = .center-of-text-overline = 带上划线的文字中心 .block-extents = 块范围 .base-point = 基点 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index c38cc4a04..b32682a8c 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5913,3 +5913,20 @@ style-manager = .center-of-text-overline = 帶上划線的文字中心 .block-extents = 圖塊範圍 .base-point = 基點 +count-feature = + .palette = Count + .menu = Count + .selection = Count Selection + .review = Review Count Details + .insert-field = Insert Count Field + .expand-by = EXPAND BY + .mirror-state = Mirror State + .area = Count in a specified area + .create-table = Create Table + .back = Back to List + .match-scale = Match Scale + .match-mirror = Match Mirror State + .error-report = Count Error Report: __ocs_fmt_0__ + .toolbar-count = Count: __ocs_fmt_0__ + .overlap = Overlapping object + .show-instances = Show Count Instances diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 671a08b77..e39367343 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -73,6 +73,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_layer_toggle", "pc_manager_toggle", "pc_manager", + "count_palette", "blocks_palette", "pdf_page_select", "pc_colormap", @@ -813,6 +814,74 @@ impl OpenCADStudio { } // Point Cloud Manager tree: "search=", "collapse", "expand", // "toggle_node=" or "select=". + // Count palette and count mode: "search=", "sort=name|count", + // "open=NAME", "child=NAME:" (an expanded row), "menu=:NAME", "create_table", "check=NAME:1", + // "all=1", "table_cancel", "table_insert", "back", "match=<0|1|2>:1", + // "details", "errors", "error=", "area", "prev", "next", "select", + // "field", "close". + "count_palette" => { + use crate::ui::window::count_palette::{CountMsg as M, RowAction}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let flag = |s: &str| s == "1" || s.eq_ignore_ascii_case("true"); + let bad = || failure("invalid_value", "Unknown count palette edit"); + Message::Count(match key { + "search" => M::Search(arg.into()), + "sort" => M::Sort(arg == "count"), + "open" => M::Open(arg.into(), codec::count::CountKey::default()), + "child" => { + let (name, n) = arg.rsplit_once(':').ok_or_else(bad)?; + let n: usize = n.parse().map_err(|_| bad())?; + let [l, s, m] = self.count_palette.expansion(name).ok_or_else(bad)?; + let i = self.active_tab; + let area = self.tabs[i].count.as_ref().and_then(|c| c.area.clone()); + let instances = codec::count::block_instances(&self.tabs[i].scene.document, area.as_deref()); + let (k, _) = codec::count::expanded_counts(&instances, name, l, s, m).into_iter().nth(n).ok_or_else(bad)?; + M::Open(name.into(), k) + } + "menu" => { + let (action, name) = arg.split_once(':').ok_or_else(bad)?; + let action = match action { + "review" => RowAction::Review, + "field" => RowAction::Field, + "layer" => RowAction::Expand(0), + "scale" => RowAction::Expand(1), + "mirror" => RowAction::Expand(2), + _ => return Err(bad()), + }; + M::Menu(name.into(), codec::count::CountKey::default(), action) + } + "create_table" => M::CreateTable, + "check" => { + let (name, on) = arg.rsplit_once(':').unwrap_or((arg, "1")); + M::TableCheck(name.into(), flag(on)) + } + "all" => M::TableAll(flag(arg)), + "table_cancel" => M::TableCancel, + "table_insert" => M::TableInsert, + "back" => M::Back, + "match" => { + let (k, on) = arg.split_once(':').unwrap_or((arg, "1")); + M::Match(k.parse::().ok().filter(|k| *k < 3).ok_or_else(bad)?, flag(on)) + } + "details" => M::ToggleDetails, + "errors" => M::ToggleErrors, + "error" => { + let i = self.active_tab; + let n: usize = arg.parse().map_err(|_| bad())?; + let mode = self.tabs[i].count.as_ref().ok_or_else(bad)?; + M::ShowError(*mode.result(&self.tabs[i].scene.document).errors.get(n).ok_or_else(bad)?) + } + "area" => M::Area, + "prev" => M::Prev, + "next" => M::Next, + "select" => M::Select, + "field" => M::Field, + "close" => M::Close, + _ => return Err(bad()), + }) + } "pc_manager" => { use crate::ui::window::pc_manager::PcManagerMsg as M; let value = string(req, "value")?; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4ec7f0109..abf76c35e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5521,6 +5521,22 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "US Survey Feet" => Some(("common", "us-survey-feet")), "to layout" => Some(("common", "to-layout")), "{count} objects selected" => Some(("common", "count-objects-selected")), + "Count palette" => Some(("count-feature", "palette")), + "Count menu" => Some(("count-feature", "menu")), + "Count Selection" => Some(("count-feature", "selection")), + "Review Count Details" => Some(("count-feature", "review")), + "Insert Count Field" => Some(("count-feature", "insert-field")), + "EXPAND BY" => Some(("count-feature", "expand-by")), + "Mirror State" => Some(("count-feature", "mirror-state")), + "Count in a specified area" => Some(("count-feature", "area")), + "Create Table" => Some(("count-feature", "create-table")), + "Back to List" => Some(("count-feature", "back")), + "Match Scale" => Some(("count-feature", "match-scale")), + "Match Mirror State" => Some(("count-feature", "match-mirror")), + "Count Error Report: {}" => Some(("count-feature", "error-report")), + "Count: {}" => Some(("count-feature", "toolbar-count")), + "Overlapping object" => Some(("count-feature", "overlap")), + "Show Count Instances" => Some(("count-feature", "show-instances")), _ => None, } } diff --git a/src/modules/view/count_palette.rs b/src/modules/view/count_palette.rs index 97321536f..fd1d4e652 100644 --- a/src/modules/view/count_palette.rs +++ b/src/modules/view/count_palette.rs @@ -4,7 +4,7 @@ pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/d pub fn tool() -> ToolDef { ToolDef { id: "COUNTLIST", - label: "Count", + label: "Count palette", icon: ICON, event: ModuleEvent::Command("_COUNTLISTTOGGLE".to_string()), } diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index cfd74c2bb..7da66744c 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -334,7 +334,7 @@ fn list_view<'a>(palette: &'a CountPalette, instances: &[BlockInstance]) -> Elem let header = row![ header_button(crate::t!("Name").into_owned(), !palette.by_count, palette.descending, CountMsg::Sort(false)), Space::new().width(Fill), - header_button(crate::t!("Count column").into_owned(), palette.by_count, palette.descending, CountMsg::Sort(true)), + header_button(crate::t!("Count").into_owned(), palette.by_count, palette.descending, CountMsg::Sort(true)), ] .align_y(iced::Center); let mut list = column![].spacing(1); @@ -478,7 +478,7 @@ pub fn view<'a>( width: f32, auto_collapse: bool, ) -> Element<'a, Message> { - let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), auto_collapse); + let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count palette").into_owned(), auto_collapse); // ponytail: the list is recounted on every view; cache by geometry epoch // if very large drawings make the palette slow. let body = match mode { From 547b89b5e75de2934fff7bde1dc88ac7974eb7f6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:10:12 +0300 Subject: [PATCH 03/11] Count: group count, list with an area, field dialog automation --- src/app/commands/count.rs | 11 +++++++---- src/app/control/actions.rs | 2 ++ src/ui/window/count_palette.rs | 13 +++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 4a30a0c10..754b6db80 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -183,7 +183,7 @@ impl OpenCADStudio { .count .as_ref() .filter(|m| m.target.is_some()) - .map(|m| m.result(&self.tabs[i].scene.document).counted.len()) + .map(|m| m.count(&self.tabs[i].scene.document)) .unwrap_or(0), ), "COUNTPALETTESTATE" => Some(self.count_palette.show as usize), @@ -374,9 +374,12 @@ impl OpenCADStudio { .collect(), _ => Vec::new(), }; - if targets.is_empty() && matches!(area_tokens.first(), Some(&"C") | Some(&"E") | None) { + // List all blocks: the palette, counting in the area when one was drawn. + if targets.is_empty() { self.set_count_palette(true); - return; + if matches!(area_tokens.first(), Some(&"C") | Some(&"E") | None) { + return; + } } let Some((area, boundary)) = self.resolve_count_area(i, area_tokens) else { return; @@ -403,7 +406,7 @@ impl OpenCADStudio { } let mode = CountMode { area, boundary, target, cursor: None, epoch: 0 }; if let Some(name) = mode.target_name() { - let n = mode.result(&self.tabs[i].scene.document).counted.len(); + let n = mode.count(&self.tabs[i].scene.document); self.command_line.push_output(&format!("{name} ...... {n}")); } self.tabs[i].count = Some(mode); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index e39367343..e49f72008 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -1088,6 +1088,8 @@ impl OpenCADStudio { "link_text" => F::HyperlinkText(v.into()), "link_url" => F::HyperlinkUrl(v.into()), "plot_scale" => F::PlotScale(index(codec::fields::PLOT_SCALE_FORMATS.len())?), + "count" => F::CountExpression(v.into()), + "show_instances" => F::ShowCountInstances, _ => return Err(failure("invalid_value", "Unknown field dialog key")), }) } diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index 7da66744c..877ad92c0 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -96,6 +96,15 @@ impl CountMode { } } + /// What the count shows: the counted references, or 1 for a group whose + /// objects all exist. + pub fn count(&self, doc: &CadDocument) -> usize { + match &self.target { + Some(CountTarget::Group(handles)) => usize::from(handles.iter().all(|h| doc.get_entity(*h).is_some())), + _ => self.result(doc).counted.len(), + } + } + /// The name the toolbar and the command line show for the target. pub fn target_name(&self) -> Option { match &self.target { @@ -421,7 +430,7 @@ fn mode_view<'a>(palette: &'a CountPalette, mode: &'a CountMode, doc: &CadDocume let mut details = column![button( row![ crate::ui::icons::themed_success(CHECK_ICON, 12.0), - text(format!("{name}: {}", result.counted.len())).size(12).width(Fill), + text(format!("{name}: {}", mode.count(doc))).size(12).width(Fill), ] .spacing(6) .align_y(iced::Center), @@ -504,7 +513,7 @@ fn bar_button<'a>(icon: Element<'a, Message>, tip: String, m: Option) pub fn toolbar<'a>(mode: &CountMode, doc: &CadDocument) -> Element<'a, Message> { let has_target = mode.target.is_some(); let result = mode.result(doc); - let count = if has_target { result.counted.len().to_string() } else { "-".to_string() }; + let count = if has_target { mode.count(doc).to_string() } else { "-".to_string() }; let status: Element<'a, Message> = if !result.errors.is_empty() { crate::ui::icons::themed_warning(WARNING_ICON, 14.0) } else if has_target { From cfc762de7435b5e58f82d6ba2460f3c25ff99dc6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:12:38 +0300 Subject: [PATCH 04/11] Cargo: codec 6d9c604 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ea12cedbc..f805f10db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "94095f7" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6d9c604" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } From d830e40bfa7ca180868afbf9261ae2e0726697ee Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:12:00 +0300 Subject: [PATCH 05/11] Count: overlapping pair in the error colour, targets deselected, locked area layer Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/commands/count.rs | 10 +++++++--- src/ui/window/count_palette.rs | 4 ++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f58675e1..d77d98502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=6d9c604#6d9c604dca78ed2f631e0f5b703415ea1f13b0db" +source = "git+https://github.com/ramox81/cadcodec.git?rev=48b86d0#48b86d08c895f07b53753888363d1d9a6b282305" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 361a526a2..51be8093f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6d9c604" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "48b86d0" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 754b6db80..101c21c65 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -253,7 +253,7 @@ impl OpenCADStudio { let r = m.result(&self.tabs[i].scene.document); crate::scene::CountDisplay { counted: r.counted.into_iter().collect(), - errors: r.errors.into_iter().collect(), + errors: r.errors.into_iter().chain(r.overlapped).collect(), color: aci_rgba(self.count_palette.color), error_color: aci_rgba(self.count_palette.error_color), } @@ -287,7 +287,8 @@ impl OpenCADStudio { let doc = &self.tabs[i].scene.document; if doc.get_entity(h).is_some() && !boundary_in_use(doc, h) { self.push_undo_snapshot(i, "COUNT"); - self.tabs[i].scene.erase_entities(&[h]); + // The area layer is locked; count mode removes its own polyline. + self.tabs[i].scene.rollback_new_entities(&[h]); self.tabs[i].dirty = true; } } @@ -335,7 +336,7 @@ impl OpenCADStudio { } } - /// A closed polyline on `0-CountArea` (colour 152, not plotted, + /// A closed polyline on `0-CountArea` (locked, colour 152, not plotted, /// Continuous), whatever the current layer. fn draw_count_area(&mut self, i: usize, ring: &[[f64; 2]]) -> Option { self.push_undo_snapshot(i, "COUNT"); @@ -345,6 +346,7 @@ impl OpenCADStudio { layer.handle = doc.allocate_handle(); layer.color = codec::types::Color::from_index(152); layer.is_plottable = false; + layer.flags.locked = true; layer.line_type = "Continuous".into(); let _ = doc.layers.add(layer); } @@ -400,6 +402,8 @@ impl OpenCADStudio { None if targets.is_empty() => None, _ => Some(CountTarget::Group(targets.clone())), }; + // The picked targets do not stay selected. + self.tabs[i].scene.deselect_all(); let old = self.tabs[i].count.take(); if let Some(old) = old.as_ref().filter(|o| o.boundary != boundary) { self.drop_count_boundary(i, old.boundary); diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index 877ad92c0..1c33dc886 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -58,6 +58,8 @@ pub struct CountMode { pub struct CountResult { pub counted: Vec, pub errors: Vec, + /// The counted references a duplicate overlaps (drawn in the error colour too). + pub overlapped: Vec, } impl CountMode { @@ -83,6 +85,7 @@ impl CountMode { Some(CountTarget::Group(handles)) => CountResult { counted: handles.iter().copied().filter(|h| doc.get_entity(*h).is_some()).collect(), errors: Vec::new(), + overlapped: Vec::new(), }, Some(CountTarget::Block { name, .. }) => { let instances = self.instances(doc); @@ -91,6 +94,7 @@ impl CountMode { CountResult { counted: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_none()).map(|i| i.handle).collect(), errors: instances.iter().filter(mine).filter(|i| i.duplicate_of.is_some()).map(|i| i.handle).collect(), + overlapped: instances.iter().filter(mine).filter_map(|i| i.duplicate_of).collect(), } } } From 0a26709d139b629205520cd3958f74bf93db354a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:36:24 +0300 Subject: [PATCH 06/11] Count: measured messages and listing, groups, Current area, boundary checks, fills and solids recoloured - COUNTCOLOR / COUNTERRORCOLOR / COUNTSERVICE reject any other value with the range message; COUNTTABLE ? lists in the reference order, padded, 22 lines a page with Press ENTER to continue:, then the totals. - Target picks report N found / N found, M total; several objects (or one that is not a block reference) count their copies, shown as Geometries in the palette. - Current area is the area in use (kept aside by Entire model space), otherwise all of model space; an invalid Object boundary ends with the reference message. - Count colours reach solids and fills; the palette list and the count are recomputed only when the drawing changes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/commands/count.rs | 48 +++++++++++------ src/app/update/file.rs | 2 +- src/app/view/mod.rs | 1 + src/locale_catalog.rs | 1 + src/modules/annotate/count_cmd.rs | 87 ++++++++++++++++++++++++------- src/scene/entity.rs | 2 + src/scene/mod.rs | 33 +++++++++++- src/ui/window/count_palette.rs | 75 +++++++++++++++++++------- 31 files changed, 214 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d77d98502..e869f168d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=48b86d0#48b86d08c895f07b53753888363d1d9a6b282305" +source = "git+https://github.com/ramox81/cadcodec.git?rev=178b596#178b596ba1606ce6d4ed719cd48551750a0f32a5" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 51be8093f..e5ef28bce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "48b86d0" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "178b596" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index d8f9892f5..8a6956780 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5955,3 +5955,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 4453f5bf9..331b7cba7 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5943,3 +5943,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index d0a43de2c..4fafb4c8d 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5940,3 +5940,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 96d321278..e2a2f8ae4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5941,3 +5941,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index db3cab564..95f7054d0 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5943,3 +5943,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 51b717a8d..764b2f5c7 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5944,3 +5944,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 3e7750281..5c228e77d 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5943,3 +5943,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index a1d1ab170..b6b1cc717 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5940,3 +5940,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 65bfde6e0..faaae44cd 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5942,3 +5942,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 21035e9e7..a7efb14dc 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5931,3 +5931,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 6906cabd4..fafcb7755 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5942,3 +5942,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f652cc7d6..5bb46d938 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5942,3 +5942,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d819a86ee..212923b58 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5931,3 +5931,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 254a0f857..4c0f4307f 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5942,3 +5942,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 105fe9086..62a0f0efc 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5941,3 +5941,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 89854dd6f..73582eaf9 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5942,3 +5942,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 395f9eef5..3c5d40863 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5943,3 +5943,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 0c6c0735f..1a532105c 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5944,3 +5944,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index faeb502e3..10943532b 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5866,3 +5866,4 @@ count-feature = .toolbar-count = Sayım: __ocs_fmt_0__ .overlap = Üst üste binen nesne .show-instances = Örnekleri göster + .geometries = Geometriler: __ocs_fmt_0__ diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index ca77965ed..3fbd610ca 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5926,3 +5926,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index d7013d976..fb702b25f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5938,3 +5938,4 @@ count-feature = .toolbar-count = Count: __ocs_fmt_0__ .overlap = Overlapping object .show-instances = Show Count Instances + .geometries = Geometries: __ocs_fmt_0__ diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 101c21c65..94fd8a73e 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -216,7 +216,7 @@ impl OpenCADStudio { self.persist_settings_if_changed(); self.apply_count_display(i); } - Ok(_) => { + _ => { self.command_line.push_error(&if max == 1 { "Requires 0 or 1 only.".to_string() } else { @@ -224,10 +224,6 @@ impl OpenCADStudio { }); ask(self); } - Err(_) => { - self.command_line.push_error("Requires an integer value."); - ask(self); - } } } @@ -249,6 +245,10 @@ impl OpenCADStudio { /// Recolour the drawing for count mode: counted references, duplicates, /// the rest faded (nothing changes without a target). pub(in crate::app) fn apply_count_display(&mut self, i: usize) { + let computed = self.tabs[i].count.as_ref().map(|m| m.compute(&self.tabs[i].scene.document)); + if let (Some(mode), Some(c)) = (self.tabs[i].count.as_mut(), computed) { + mode.cache = Some(c); + } let display = self.tabs[i].count.as_ref().filter(|m| m.target.is_some()).map(|m| { let r = m.result(&self.tabs[i].scene.document); crate::scene::CountDisplay { @@ -278,6 +278,7 @@ impl OpenCADStudio { pub(in crate::app) fn close_count(&mut self, i: usize) { if let Some(mode) = self.tabs[i].count.take() { self.drop_count_boundary(i, mode.boundary); + self.drop_count_boundary(i, mode.remembered.and_then(|(_, b)| b)); } self.tabs[i].scene.set_count_display(None); } @@ -310,10 +311,13 @@ impl OpenCADStudio { let mode = self.tabs[i].count.as_ref(); Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary))) } - "C" => { - let (x0, y0, x1, y1) = self.display_plot_window()?; - Some((Some(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]), None)) - } + // Current area: the count area in use (also one Entire model space + // set aside), else all of model space. + "C" => Some(match self.tabs[i].count.as_ref() { + Some(m) if m.area.is_some() => (m.area.clone(), m.boundary), + Some(m) => m.remembered.clone().map_or((None, None), |(a, b)| (Some(a), b)), + None => (None, None), + }), "R" => { let p = points(); let (a, b) = (p.first()?, p.get(1)?); @@ -404,11 +408,18 @@ impl OpenCADStudio { }; // The picked targets do not stay selected. self.tabs[i].scene.deselect_all(); - let old = self.tabs[i].count.take(); - if let Some(old) = old.as_ref().filter(|o| o.boundary != boundary) { - self.drop_count_boundary(i, old.boundary); + // Entire model space counts without the area but keeps it for a later + // Current area; a new area replaces it. + let mut remembered = None; + if let Some(old) = self.tabs[i].count.take() { + let old_area = old.area.map(|a| (a, old.boundary)).or(old.remembered); + if area.is_none() { + remembered = old_area; + } else if let Some((_, b)) = old_area.filter(|(_, b)| *b != boundary) { + self.drop_count_boundary(i, b); + } } - let mode = CountMode { area, boundary, target, cursor: None, epoch: 0 }; + let mode = CountMode { area, boundary, target, remembered, ..CountMode::default() }; if let Some(name) = mode.target_name() { let n = mode.count(&self.tabs[i].scene.document); self.command_line.push_output(&format!("{name} ...... {n}")); @@ -566,13 +577,16 @@ impl OpenCADStudio { self.tabs[i].dirty = true; } - /// Zoom to a reference (← / →, an error of the report). + /// Zoom to a counted object (← / →, an error of the report). fn zoom_to_insert(&mut self, i: usize, h: Handle) { let doc = &self.tabs[i].scene.document; - let Some(EntityType::Insert(ins)) = doc.get_entity(h) else { return }; - let Some(corners) = codec::count::insert_corners(doc, ins, 0) else { return }; + let Some(entity) = doc.get_entity(h) else { return }; + let outline = codec::count::entity_outline(doc, entity); + if outline.iter().all(|part| part.is_empty()) { + return; + } let (mut lo, mut hi) = (glam::Vec3::splat(f32::MAX), glam::Vec3::splat(f32::MIN)); - for c in corners { + for c in outline.into_iter().flatten() { let p = glam::Vec3::new(c.x as f32, c.y as f32, c.z as f32); lo = lo.min(p); hi = hi.max(p); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ecc041ff1..4fd0f783d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -5544,7 +5544,7 @@ impl OpenCADStudio { /// Current visible rectangle in the active space. The result is deliberately /// not clamped to the paper sheet: Display and Window may include the grey /// canvas outside the sheet, matching Model-space plotting. - pub(in crate::app) fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> { + fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> { let scene = &self.tabs[self.active_tab].scene; let (canvas_w, canvas_h) = scene.selection.borrow().vp_size; let viewport = if scene.current_layout == "Model" { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0c2db547f..8528c1c9e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -3035,6 +3035,7 @@ impl OpenCADStudio { &self.count_palette, tab.count.as_ref(), &tab.scene.document, + tab.scene.geometry_epoch, width, auto_collapse, ), diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 78d01a199..e1526f410 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5545,6 +5545,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Count: {}" => Some(("count-feature", "toolbar-count")), "Overlapping object" => Some(("count-feature", "overlap")), "Show Count Instances" => Some(("count-feature", "show-instances")), + "Geometries: {}" => Some(("count-feature", "geometries")), _ => None, } } diff --git a/src/modules/annotate/count_cmd.rs b/src/modules/annotate/count_cmd.rs index 8e75a99ab..9b0517d6b 100644 --- a/src/modules/annotate/count_cmd.rs +++ b/src/modules/annotate/count_cmd.rs @@ -144,8 +144,16 @@ impl CadCommand for CountCommand { } fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + // The selection arrives whole after every pick: `1 found`, then + // `1 found, 2 total`. + let found = handles.iter().filter(|h| !self.targets.contains(h)).count(); + let first = self.targets.is_empty(); self.targets = handles; - CmdResult::ReportMeasurement(format!("{} found", self.targets.len())) + CmdResult::ReportMeasurement(if first { + format!("{found} found") + } else { + format!("{found} found, {} total", self.targets.len()) + }) } fn needs_entity_pick(&self) -> bool { @@ -162,11 +170,13 @@ impl CadCommand for CountCommand { fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { match self.picked.take() { - Some(EntityType::LwPolyline(pl)) if pl.is_closed && pl.vertices.len() >= 3 => { + Some(EntityType::LwPolyline(pl)) if codec::count::valid_boundary(&pl) => { self.area_done(format!("O {:X}", handle.value())) } - // Anything else: the prompt again. - _ => CmdResult::NeedPoint, + _ => CmdResult::CancelWithMessage( + "Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself." + .into(), + ), } } @@ -244,6 +254,8 @@ pub struct CountTableCommand { blocks: Vec, others: [usize; 3], names: Vec, + /// Listing lines still to show after `Press ENTER to continue:`. + pending: Vec, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -251,38 +263,75 @@ enum TableStep { Names, List, Point, + Page, } impl CountTableCommand { pub fn new(blocks: Vec, others: [usize; 3]) -> Self { - Self { step: TableStep::Names, blocks, others, names: Vec::new() } + Self { step: TableStep::Names, blocks, others, names: Vec::new(), pending: Vec::new() } } /// The palette's Create Table: the blocks are chosen, only the point is asked. pub fn placing(names: Vec) -> Self { - Self { step: TableStep::Point, blocks: Vec::new(), others: [0; 3], names } + Self { step: TableStep::Point, blocks: Vec::new(), others: [0; 3], names, pending: Vec::new() } } - /// `?`: the defined blocks matching the pattern and the summary. - fn listing(&self, pattern: &str) -> String { + /// `?`: the defined blocks matching the pattern, paged by 22 lines + /// (`Press ENTER to continue:` in between), then the summary. + fn listing(&mut self, pattern: &str) -> CmdResult { let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; - let mut out = String::from("Defined blocks."); - for name in &self.blocks { - if pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) { - out.push_str(&format!("\n \"{name}\"")); - } + let mut names: Vec<&String> = self + .blocks + .iter() + .filter(|name| pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim()))) + .collect(); + names.sort_by_key(|name| list_order(name)); + self.pending = std::iter::once(String::new()) + .chain(std::iter::once("Defined blocks.".to_string())) + .chain(names.iter().map(|name| format!(" {:<31}", format!("\"{name}\"")))) + .collect(); + self.next_page() + } + + fn next_page(&mut self) -> CmdResult { + let page: Vec = self.pending.drain(..self.pending.len().min(22)).collect(); + if !self.pending.is_empty() { + self.step = TableStep::Page; + return CmdResult::ReportMeasurement(page.join(" +")); } - out.push_str(&format!( - "\n\nUser External Dependent Unnamed\nBlocks References Blocks Blocks\n{:>5}{:>11}{:>13}{:>12}", + CmdResult::Measurement(format!( + "{} + +User External Dependent Unnamed +Blocks References Blocks Blocks +{:>5}{:>10}{:>12}{:>12} +", + page.join(" +"), self.blocks.len(), self.others[0], self.others[1], self.others[2] - )); - out + )) } } +/// Block listing order: `_` first, then `-`, space and other punctuation, +/// digits, and letters without case. +fn list_order(name: &str) -> Vec<(u32, u32)> { + name.chars() + .map(|c| match c { + '_' => (0, 0), + '-' => (1, 0), + ' ' => (2, 0), + '0'..='9' => (4, c as u32), + c if c.is_alphabetic() => (5, c.to_lowercase().next().unwrap_or(c) as u32), + c => (3, c as u32), + }) + .collect() +} + impl CadCommand for CountTableCommand { fn name(&self) -> &'static str { "COUNTTABLE" @@ -293,6 +342,7 @@ impl CadCommand for CountTableCommand { TableStep::Names => "Enter block name(s) to include or [?] :".into(), TableStep::List => "Enter block(s) to list <*>:".into(), TableStep::Point => "Specify insertion point:".into(), + TableStep::Page => "Press ENTER to continue:".into(), } } @@ -327,7 +377,8 @@ impl CadCommand for CountTableCommand { self.step = TableStep::Point; CmdResult::NeedPoint } - TableStep::List => CmdResult::Measurement(self.listing(text)), + TableStep::List => self.listing(text), + TableStep::Page => self.next_page(), TableStep::Point => return None, }) } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 9c22246bb..fcb98ef22 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1451,6 +1451,7 @@ impl Scene { } } } + m.color = self.count_color(handle, m.color, self.current_bg()); if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } @@ -1752,6 +1753,7 @@ impl Scene { ) }); } + model.color = self.count_color(context.root_handle, model.color, hatch_bg); if tint_selected && self.selected.contains(&context.root_handle) { model.color = [0.15, 0.55, 1.00, model.color[3]]; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59e26575b..60d442f3f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -4007,6 +4007,9 @@ impl Scene { ); } } + if self.meshes.contains_key(&h) { + material.diffuse = self.count_color(h, material.diffuse, bg); + } (h, material) }) }) @@ -4108,9 +4111,28 @@ impl Scene { return; } self.count_display = display; + self.hatch_cache.borrow_mut().clear(); + self.recolor_meshes(); self.bump_geometry_no_blocks(); } + /// The colour count mode gives an object drawn as part of `handle` (a + /// top-level entity): the count colours, else faded; unchanged outside + /// count mode. + pub(crate) fn count_color(&self, handle: Handle, color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + let Some(count) = &self.count_display else { + return color; + }; + let [r, g, b, _] = if count.errors.contains(&handle) { + count.error_color + } else if count.counted.contains(&handle) { + count.color + } else { + return crate::scene::cache::block_cache::fade_toward_bg(color, bg); + }; + [r, g, b, color[3]] + } + /// Fade the colours of wires that belong to entities outside the REFEDIT /// keep set (no-op when not editing). The geometry is untouched, so /// hit-testing still works on faded entities. In count mode the counted @@ -4118,8 +4140,7 @@ impl Scene { fn apply_refedit_fade(&self, wires: &mut [WireModel], bg: [f32; 4]) { if let Some(count) = &self.count_display { for w in wires.iter_mut() { - let h = Self::handle_from_wire_name(&w.name); - w.color = match h { + w.color = match Self::handle_from_wire_name(&w.name) { Some(h) if count.errors.contains(&h) => count.error_color, Some(h) if count.counted.contains(&h) => count.color, _ => crate::scene::cache::block_cache::fade_toward_bg(w.color, bg), @@ -8745,6 +8766,14 @@ impl Scene { self.material_base_dir.as_deref(), ); } + if self.count_display.is_some() { + let own = transformed.instance_color.or(set.display_color()).unwrap_or([1.0; 4]); + let color = self.count_color(context.root_handle, own, self.current_bg()); + transformed.instance_color = Some(color); + if let Some(material) = transformed.material.as_mut() { + material.diffuse = color; + } + } transformed.instance_handle = Some(context.root_handle); out.push(transformed); }, diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index 1c33dc886..8802d0ca5 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -33,7 +33,8 @@ pub enum CountTarget { matching: [bool; 3], picked: bool, }, - /// Several picked objects counted as one group. + /// Picked objects (several, or one that is not a reference): the copies + /// of the group are counted. Group(Vec), } @@ -50,6 +51,10 @@ pub struct CountMode { pub cursor: Option, /// The scene geometry epoch the colouring was computed at. pub epoch: u64, + /// The area Entire model space set aside, for a later Current area. + pub remembered: Option<(Vec<[f64; 2]>, Option<(Handle, bool)>)>, + /// The result and count at `epoch` (recomputed when the drawing changes). + pub cache: Option<(CountResult, usize)>, } /// A count mode's result: the counted references and the overlapping @@ -80,13 +85,35 @@ impl CountMode { } pub fn result(&self, doc: &CadDocument) -> CountResult { + match &self.cache { + Some((result, _)) => result.clone(), + None => self.compute(doc).0, + } + } + + /// What the count shows: the counted references, or the copies of a group. + pub fn count(&self, doc: &CadDocument) -> usize { + match &self.cache { + Some((_, n)) => *n, + None => self.compute(doc).1, + } + } + + /// The result and the count, worked out from the drawing. + pub fn compute(&self, doc: &CadDocument) -> (CountResult, usize) { + if let Some(CountTarget::Group(handles)) = &self.target { + let groups = codec::count::group_matches(doc, handles, self.area.as_deref()); + let result = CountResult { counted: groups.concat(), errors: Vec::new(), overlapped: Vec::new() }; + return (result, groups.len()); + } + let result = self.block_result(doc); + let n = result.counted.len(); + (result, n) + } + + fn block_result(&self, doc: &CadDocument) -> CountResult { match &self.target { - None => CountResult::default(), - Some(CountTarget::Group(handles)) => CountResult { - counted: handles.iter().copied().filter(|h| doc.get_entity(*h).is_some()).collect(), - errors: Vec::new(), - overlapped: Vec::new(), - }, + None | Some(CountTarget::Group(_)) => CountResult::default(), Some(CountTarget::Block { name, .. }) => { let instances = self.instances(doc); let key = self.key(&instances); @@ -100,15 +127,6 @@ impl CountMode { } } - /// What the count shows: the counted references, or 1 for a group whose - /// objects all exist. - pub fn count(&self, doc: &CadDocument) -> usize { - match &self.target { - Some(CountTarget::Group(handles)) => usize::from(handles.iter().all(|h| doc.get_entity(*h).is_some())), - _ => self.result(doc).counted.len(), - } - } - /// The name the toolbar and the command line show for the target. pub fn target_name(&self) -> Option { match &self.target { @@ -137,6 +155,8 @@ pub struct CountPalette { pub color: i16, pub error_color: i16, pub service: bool, + /// The list's references for (document, geometry epoch, area). + pub list_cache: std::cell::RefCell>, std::rc::Rc>)>>, } impl Default for CountPalette { @@ -153,6 +173,7 @@ impl Default for CountPalette { color: 3, error_color: 1, service: true, + list_cache: Default::default(), } } } @@ -434,7 +455,12 @@ fn mode_view<'a>(palette: &'a CountPalette, mode: &'a CountMode, doc: &CadDocume let mut details = column![button( row![ crate::ui::icons::themed_success(CHECK_ICON, 12.0), - text(format!("{name}: {}", mode.count(doc))).size(12).width(Fill), + text(match mode.target { + Some(CountTarget::Group(_)) => crate::tf!("Geometries: {}", mode.count(doc)).into_owned(), + _ => format!("{name}: {}", mode.count(doc)), + }) + .size(12) + .width(Fill), ] .spacing(6) .align_y(iced::Center), @@ -488,16 +514,25 @@ pub fn view<'a>( palette: &'a CountPalette, mode: Option<&'a CountMode>, doc: &'a CadDocument, + epoch: u64, width: f32, auto_collapse: bool, ) -> Element<'a, Message> { let title_bar = crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count palette").into_owned(), auto_collapse); - // ponytail: the list is recounted on every view; cache by geometry epoch - // if very large drawings make the palette slow. let body = match mode { Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc), _ => { - let instances = codec::count::block_instances(doc, mode.and_then(|m| m.area.as_deref())); + let area = mode.and_then(|m| m.area.clone()); + let key = (doc as *const CadDocument as usize, epoch); + let mut cache = palette.list_cache.borrow_mut(); + let instances = match cache.as_ref() { + Some((d, e, a, list)) if (*d, *e) == key && *a == area => list.clone(), + _ => { + let list = std::rc::Rc::new(codec::count::block_instances(doc, area.as_deref())); + *cache = Some((key.0, key.1, area, list.clone())); + list + } + }; list_view(palette, &instances) } }; From 789687659ba23a454c34f6e360f8b7f0b352def8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:54:22 +0300 Subject: [PATCH 07/11] Count: Current area counts all of model space; Properties cleared after the pick Co-Authored-By: Claude Opus 5.5 --- src/app/commands/count.rs | 26 +++++++------------------- src/ui/window/count_palette.rs | 2 -- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 94fd8a73e..3dcbcd259 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -278,7 +278,6 @@ impl OpenCADStudio { pub(in crate::app) fn close_count(&mut self, i: usize) { if let Some(mode) = self.tabs[i].count.take() { self.drop_count_boundary(i, mode.boundary); - self.drop_count_boundary(i, mode.remembered.and_then(|(_, b)| b)); } self.tabs[i].scene.set_count_display(None); } @@ -311,13 +310,9 @@ impl OpenCADStudio { let mode = self.tabs[i].count.as_ref(); Some((mode.and_then(|m| m.area.clone()), mode.and_then(|m| m.boundary))) } - // Current area: the count area in use (also one Entire model space - // set aside), else all of model space. - "C" => Some(match self.tabs[i].count.as_ref() { - Some(m) if m.area.is_some() => (m.area.clone(), m.boundary), - Some(m) => m.remembered.clone().map_or((None, None), |(a, b)| (Some(a), b)), - None => (None, None), - }), + // Current area counts all of model space (the view and an area + // in use take no part). + "C" => Some((None, None)), "R" => { let p = points(); let (a, b) = (p.first()?, p.get(1)?); @@ -408,18 +403,11 @@ impl OpenCADStudio { }; // The picked targets do not stay selected. self.tabs[i].scene.deselect_all(); - // Entire model space counts without the area but keeps it for a later - // Current area; a new area replaces it. - let mut remembered = None; - if let Some(old) = self.tabs[i].count.take() { - let old_area = old.area.map(|a| (a, old.boundary)).or(old.remembered); - if area.is_none() { - remembered = old_area; - } else if let Some((_, b)) = old_area.filter(|(_, b)| *b != boundary) { - self.drop_count_boundary(i, b); - } + self.refresh_properties(); + if let Some(old) = self.tabs[i].count.take().filter(|o| o.boundary != boundary) { + self.drop_count_boundary(i, old.boundary); } - let mode = CountMode { area, boundary, target, remembered, ..CountMode::default() }; + let mode = CountMode { area, boundary, target, ..CountMode::default() }; if let Some(name) = mode.target_name() { let n = mode.count(&self.tabs[i].scene.document); self.command_line.push_output(&format!("{name} ...... {n}")); diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index 8802d0ca5..f444a2266 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -51,8 +51,6 @@ pub struct CountMode { pub cursor: Option, /// The scene geometry epoch the colouring was computed at. pub epoch: u64, - /// The area Entire model space set aside, for a later Current area. - pub remembered: Option<(Vec<[f64; 2]>, Option<(Handle, bool)>)>, /// The result and count at `epoch` (recomputed when the drawing changes). pub cache: Option<(CountResult, usize)>, } From 4dba66bbac46f853124a89d8cc03899a53e2b3d0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:06:58 +0300 Subject: [PATCH 08/11] Count: table drawn into its block with fields in the cell texts, polygon prompt, exact areas Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/commands/count.rs | 66 +++++++++++++++++++++---------- src/modules/annotate/count_cmd.rs | 5 ++- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e869f168d..6b96c417e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=178b596#178b596ba1606ce6d4ed719cd48551750a0f32a5" +source = "git+https://github.com/ramox81/cadcodec.git?rev=483c006#483c006df06edeb371af25a39f975007dd91f8ca" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index e5ef28bce..83e94433a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "178b596" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "483c006" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 3dcbcd259..6fd6d0b92 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -530,10 +530,42 @@ impl OpenCADStudio { .column_width(3.6) .build(); table.set_column_width(1, 1.08); + // As the reference writes it: no title row, data rows centred + // (table and cell overrides), header cells without overrides. + table.value_flags = 22; + table.legacy_style_override = Some(codec::entities::table::LegacyTableStyleOverride { + flags: 0x10001, + title_suppressed: Some(true), + row_alignments: vec![5], + ..Default::default() + }); table.set_cell_text(0, 0, "Item"); table.set_cell_text(0, 1, "Count"); for (r, name) in names.iter().enumerate() { table.set_cell_text(r + 1, 0, name); + if let Some(cell) = table.cell_mut(r + 1, 1) { + // The count comes from the field; the cell keeps no text. + let mut content = codec::entities::table::CellContent::text(""); + content.value.flags = 1; + cell.contents = vec![content]; + cell.cell_type = codec::entities::table::CellType::Text; + } + } + for (r, row) in table.rows.iter_mut().enumerate() { + for cell in &mut row.cells { + if let Some(content) = cell.contents.first_mut().filter(|c| c.value.flags != 1) { + content.value.flags = 6; + } + if r == 0 { + cell.flag = 0x40000; + } else { + cell.flag = 0x40001; + let mut style = codec::entities::CellStyle::new(); + style.override_flags = 0x01; + style.alignment = 5; + cell.style = Some(style); + } + } } let codes: Vec = names.iter().map(|n| self.block_field_code(i, n, &CountKey::default())).collect(); @@ -541,26 +573,18 @@ impl OpenCADStudio { let Some(handle) = self.commit_entity_handle(EntityType::Table(Box::new(table))) else { return; }; + // The table's graphics block holds one MTEXT per cell; each count + // cell's MTEXT owns its field, which the cell refers to. let doc = &mut self.tabs[i].scene.document; - let mut fields = Vec::new(); - for code in &codes { - let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into()); - let field = doc.new_table_cell_field(handle, "%<\\_FldIdx 0>%", vec![codec::fields::NewField::new(code.clone(), value.clone())]); - fields.push((field, value)); - } - if let Some(EntityType::Table(table)) = doc.get_entity_mut(handle) { - for (r, (field, value)) in fields.into_iter().enumerate() { - let Some(field) = field else { continue }; - if let Some(cell) = table.cell_mut(r + 1, 1) { - let mut content = codec::entities::table::CellContent::text(&value); - content.field_handle = Some(field); - cell.contents.clear(); - cell.contents.push(content); - cell.cell_type = codec::entities::table::CellType::Text; - } - table.field_handles.push(field); - } - } + let fields = codes + .iter() + .enumerate() + .map(|(r, code)| { + let value = crate::entities::field::evaluate(doc, code, &[], None).unwrap_or_else(|| "####".into()); + (r + 1, 1, r"%<\_FldIdx 0>%".to_string(), vec![codec::fields::NewField::new(code.clone(), value)]) + }) + .collect(); + doc.build_table_block(handle, fields); self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } @@ -570,11 +594,11 @@ impl OpenCADStudio { let doc = &self.tabs[i].scene.document; let Some(entity) = doc.get_entity(h) else { return }; let outline = codec::count::entity_outline(doc, entity); - if outline.iter().all(|part| part.is_empty()) { + if outline.is_empty() { return; } let (mut lo, mut hi) = (glam::Vec3::splat(f32::MAX), glam::Vec3::splat(f32::MIN)); - for c in outline.into_iter().flatten() { + for c in outline.iter().flat_map(codec::count::Piece::points) { let p = glam::Vec3::new(c.x as f32, c.y as f32, c.z as f32); lo = lo.min(p); hi = hi.max(p); diff --git a/src/modules/annotate/count_cmd.rs b/src/modules/annotate/count_cmd.rs index 9b0517d6b..f2be96126 100644 --- a/src/modules/annotate/count_cmd.rs +++ b/src/modules/annotate/count_cmd.rs @@ -3,7 +3,7 @@ // Specify first corner point of the count area or // [Current area/Entire model space/Object/Polygonal] : // Specify opposite corner: (Polygonal: Specify start point: / -// Specify next point:; Object: +// Specify next point; Object: // Select object as count area boundary:) // Select target objects or [List all blocks] : (COUNT) // @@ -109,7 +109,8 @@ impl CadCommand for CountCommand { Step::Area => "Specify first corner point of the count area or [Current area/Entire model space/Object/Polygonal] :".into(), Step::Corner => "Specify opposite corner:".into(), Step::PolyStart => "Specify start point:".into(), - Step::PolyNext => "Specify next point:".into(), + // The reference shows this one without a colon. + Step::PolyNext => "Specify next point".into(), Step::Object => "Select object as count area boundary:".into(), Step::Targets => "Select target objects or [List all blocks] :".into(), } From 7ad6e5b094a7f9feae8549df4b9fc2de68833da2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:40:02 +0300 Subject: [PATCH 09/11] Count: Invalid Area dialog, table block kept with the table - When the count area's boundary is gone the approved Invalid Area dialog asks: undo the change (also its X and Cancel) or count all of model space; "always" keeps the choice in the settings. An edited boundary moves the area. - A table's *T block is brought up to date whenever the table changes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 7 ++++ locales/bg-BG/opencadstudio.ftl | 7 ++++ locales/cs-CZ/opencadstudio.ftl | 7 ++++ locales/de-DE/opencadstudio.ftl | 7 ++++ locales/el-GR/opencadstudio.ftl | 7 ++++ locales/en-US/opencadstudio.ftl | 7 ++++ locales/es-ES/opencadstudio.ftl | 7 ++++ locales/fi-FI/opencadstudio.ftl | 7 ++++ locales/fr-FR/opencadstudio.ftl | 7 ++++ locales/hi-IN/opencadstudio.ftl | 7 ++++ locales/hu-HU/opencadstudio.ftl | 7 ++++ locales/it-IT/opencadstudio.ftl | 7 ++++ locales/ja-JP/opencadstudio.ftl | 7 ++++ locales/ko-KR/opencadstudio.ftl | 7 ++++ locales/nl-NL/opencadstudio.ftl | 7 ++++ locales/pl-PL/opencadstudio.ftl | 7 ++++ locales/pt-BR/opencadstudio.ftl | 7 ++++ locales/ru-RU/opencadstudio.ftl | 7 ++++ locales/tr-TR/opencadstudio.ftl | 7 ++++ locales/zh-CN/opencadstudio.ftl | 7 ++++ locales/zh-TW/opencadstudio.ftl | 7 ++++ src/app/commands/count.rs | 68 ++++++++++++++++++++++++++++++++- src/app/control/actions.rs | 6 ++- src/app/mod.rs | 1 + src/app/settings.rs | 4 ++ src/app/update/file.rs | 2 + src/app/update/mod.rs | 4 ++ src/app/view/modal.rs | 4 ++ src/locale_catalog.rs | 7 ++++ src/scene/mod.rs | 9 +++++ src/ui/window/count_palette.rs | 67 ++++++++++++++++++++++++++++++++ 33 files changed, 318 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b96c417e..40b4fb9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=483c006#483c006df06edeb371af25a39f975007dd91f8ca" +source = "git+https://github.com/ramox81/cadcodec.git?rev=115b753#115b7536c7c00bdd9684a80f2bb7642c11b048e9" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 83e94433a..a1dedd3f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "483c006" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "115b753" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 8a6956780..72d8e4db4 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5956,3 +5956,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 331b7cba7..c07082a2a 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5944,3 +5944,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 4fafb4c8d..2d98d426d 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5941,3 +5941,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index e2a2f8ae4..b37388bca 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5942,3 +5942,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 95f7054d0..156d8d87c 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5944,3 +5944,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 764b2f5c7..92e1f15c0 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5945,3 +5945,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 5c228e77d..1ff70cdd5 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5944,3 +5944,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index b6b1cc717..b6ad791b8 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5941,3 +5941,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index faaae44cd..624cfcae7 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5943,3 +5943,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index a7efb14dc..bcb1e45bf 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5932,3 +5932,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index fafcb7755..71089bf43 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5943,3 +5943,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 5bb46d938..87e46727e 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5943,3 +5943,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 212923b58..4b26c5689 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5932,3 +5932,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 4c0f4307f..f25a45ad6 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5943,3 +5943,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 62a0f0efc..c488075de 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5942,3 +5942,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 73582eaf9..a6d2dc415 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5943,3 +5943,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3c5d40863..6756aeac9 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5944,3 +5944,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 1a532105c..2e370254d 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5945,3 +5945,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 10943532b..25d2d556f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -5867,3 +5867,10 @@ count-feature = .overlap = Üst üste binen nesne .show-instances = Örnekleri göster .geometries = Geometriler: __ocs_fmt_0__ + .invalid-title = Sayım - Geçersiz Alan + .invalid-message = Sayım sınırındaki değişiklikler geçersiz bir sayım alanıyla sonuçlandı. Ne yapmak istersiniz? + .invalid-undo = Sayım sınırındaki değişiklikleri geri al + .invalid-undo-detail = Son işlem geri alınır, sınır ve sayım alanı eski haline döner. + .invalid-continue = Devam et ve tüm model alanını say + .invalid-continue-detail = Sayım alanı kalkar; sayım tüm model alanında sürer. + .invalid-always = Geçerli seçimimi her zaman uygula diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 3fbd610ca..45e7c5f3c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5927,3 +5927,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fb702b25f..19ede3dfd 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5939,3 +5939,10 @@ count-feature = .overlap = Overlapping object .show-instances = Show Count Instances .geometries = Geometries: __ocs_fmt_0__ + .invalid-title = Count - Invalid Area + .invalid-message = The changes to count boundary have resulted to an invalid count area. What do you want to do? + .invalid-undo = Undo the changes to the count boundary + .invalid-undo-detail = The last change is undone; the boundary and the count area come back. + .invalid-continue = Continue and count the entire model space + .invalid-continue-detail = The count area is dropped; counting goes on in all of model space. + .invalid-always = Always perform my current choice diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index 6fd6d0b92..d10041a43 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -268,8 +268,63 @@ impl OpenCADStudio { /// After the drawing changes, count mode colours its references again. pub(in crate::app) fn refresh_count_if_stale(&mut self) { let i = self.active_tab; - if self.tabs[i].count.as_ref().is_some_and(|m| m.epoch != self.tabs[i].scene.geometry_epoch) { - self.apply_count_display(i); + let Some(mode) = self.tabs[i].count.as_ref().filter(|m| m.epoch != self.tabs[i].scene.geometry_epoch) else { + return; + }; + if let Some((h, _)) = mode.boundary { + match self.tabs[i].scene.document.get_entity(h) { + // The boundary is gone (erased, undone): ask, or do what was + // chosen for good. + None => { + if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) { + return; + } + match self.count_palette.invalid_choice { + 1 => self.count_invalid_undo(i), + 2 => self.count_invalid_continue(i), + _ => { + self.count_palette.invalid_always = false; + self.active_modal = Some(crate::app::ModalKind::CountInvalidArea); + } + } + return; + } + // An edited boundary moves the area with it. + Some(EntityType::LwPolyline(pl)) => { + let ring: Vec<[f64; 2]> = pl.vertices.iter().map(|v| [v.location.x, v.location.y]).collect(); + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.area = Some(ring); + } + } + Some(_) => {} + } + } + self.apply_count_display(i); + } + + /// Invalid Area › Undo (also ✕ and Cancel): the change that took the + /// boundary away is undone. + fn count_invalid_undo(&mut self, i: usize) { + self.undo_active_tab(); + self.apply_count_display(i); + } + + /// Invalid Area › Continue: the area goes, the count covers all of model space. + fn count_invalid_continue(&mut self, i: usize) { + if let Some(mode) = self.tabs[i].count.as_mut() { + mode.area = None; + mode.boundary = None; + } + self.apply_count_display(i); + } + + fn close_invalid_area(&mut self, choice: u8) { + if self.active_modal == Some(crate::app::ModalKind::CountInvalidArea) { + self.close_active_modal(); + } + if self.count_palette.invalid_always { + self.count_palette.invalid_choice = choice; + self.persist_settings_if_changed(); } } @@ -755,6 +810,15 @@ impl OpenCADStudio { return self.start_count_command(i, Box::new(command), "COUNT"); } CountMsg::Field => return self.dispatch_command("COUNTFIELD"), + CountMsg::InvalidAlways(v) => self.count_palette.invalid_always = v, + CountMsg::InvalidUndo => { + self.close_invalid_area(1); + self.count_invalid_undo(i); + } + CountMsg::InvalidContinue => { + self.close_invalid_area(2); + self.count_invalid_continue(i); + } } Task::none() } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index d40ccbae7..9d6963605 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -933,7 +933,7 @@ impl OpenCADStudio { // layer|scale|mirror>:NAME", "create_table", "check=NAME:1", // "all=1", "table_cancel", "table_insert", "back", "match=<0|1|2>:1", // "details", "errors", "error=", "area", "prev", "next", "select", - // "field", "close". + // "field", "close", "invalid_undo", "invalid_continue", "invalid_always=1". "count_palette" => { use crate::ui::window::count_palette::{CountMsg as M, RowAction}; let value = string(req, "value")?; @@ -993,6 +993,10 @@ impl OpenCADStudio { "select" => M::Select, "field" => M::Field, "close" => M::Close, + // The Invalid Area dialog. + "invalid_undo" => M::InvalidUndo, + "invalid_continue" => M::InvalidContinue, + "invalid_always" => M::InvalidAlways(arg == "1"), _ => return Err(bad()), }) } diff --git a/src/app/mod.rs b/src/app/mod.rs index d1b4665cf..9183c3bd5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1943,6 +1943,7 @@ pub enum ModalKind { GeometricTolerance, AttDef, AttDefEdit, + CountInvalidArea, Field, DraftingSettings, AutoConstrainSettings, diff --git a/src/app/settings.rs b/src/app/settings.rs index a8d302a39..89e1728ae 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -622,6 +622,9 @@ pub struct UserSettings { /// COUNTSERVICE (default on). #[serde(default = "default_true")] pub count_service: bool, + /// Count - Invalid Area: 0 ask, 1 undo, 2 count all of model space. + #[serde(default)] + pub count_invalid_area: u8, } /// A block the Blocks palette lists from outside the current drawing (a @@ -873,6 +876,7 @@ impl Default for UserSettings { count_color: 3, count_error_color: 1, count_service: true, + count_invalid_area: 0, } } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4fd0f783d..42e997535 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -748,6 +748,7 @@ impl OpenCADStudio { count_color: self.count_palette.color, count_error_color: self.count_palette.error_color, count_service: self.count_palette.service, + count_invalid_area: self.count_palette.invalid_choice, } } @@ -882,6 +883,7 @@ impl OpenCADStudio { self.count_palette.color = s.count_color; self.count_palette.error_color = s.count_error_color; self.count_palette.service = s.count_service; + self.count_palette.invalid_choice = s.count_invalid_area.min(2); self.block_palette.recent = s.block_recent.clone(); self.block_palette.favorites = s.block_favorites.clone(); self.block_palette.libraries = s.block_libraries.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6408592db..6f1ab0b64 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8524,6 +8524,10 @@ impl OpenCADStudio { } Message::CloseModal => { + // The Invalid Area dialog's ✕ undoes the boundary change, as its Cancel does. + if self.active_modal == Some(super::ModalKind::CountInvalidArea) { + return self.update(Message::Count(crate::ui::window::count_palette::CountMsg::InvalidUndo)); + } if self.active_modal == Some(super::ModalKind::Field) { self.close_field_dialog(); return Task::none(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index d5f81eae6..5f693e757 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -40,6 +40,7 @@ impl OpenCADStudio { Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::AttDef) => crate::t!("Attribute Definition").into_owned(), Some(K::AttDefEdit) => crate::t!("Edit Attribute Definition").into_owned(), + Some(K::CountInvalidArea) => crate::t!("Count - Invalid Area").into_owned(), Some(K::Field) => crate::t!("Field").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), @@ -584,6 +585,9 @@ impl OpenCADStudio { let state = self.field_dialog.as_ref()?; sized_flow(ex, 760, 660, |flow| crate::ui::window::field_dialog::view(state, flow)) } + super::super::ModalKind::CountInvalidArea => { + sized_flow(ex, 520, 260, |flow| crate::ui::window::count_palette::invalid_area_view(&self.count_palette, flow)) + } super::super::ModalKind::AttDefEdit => { let state = self.attdef_edit.as_ref()?; sized_flow(ex, 460, 220, |flow| crate::ui::window::attdef_dialog::view_edit(state, flow)) diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index e1526f410..4d0f65539 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5546,6 +5546,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Overlapping object" => Some(("count-feature", "overlap")), "Show Count Instances" => Some(("count-feature", "show-instances")), "Geometries: {}" => Some(("count-feature", "geometries")), + "Count - Invalid Area" => Some(("count-feature", "invalid-title")), + "The changes to count boundary have resulted to an invalid count area. What do you want to do?" => Some(("count-feature", "invalid-message")), + "Undo the changes to the count boundary" => Some(("count-feature", "invalid-undo")), + "The last change is undone; the boundary and the count area come back." => Some(("count-feature", "invalid-undo-detail")), + "Continue and count the entire model space" => Some(("count-feature", "invalid-continue")), + "The count area is dropped; counting goes on in all of model space." => Some(("count-feature", "invalid-continue-detail")), + "Always perform my current choice" => Some(("count-feature", "invalid-always")), _ => None, } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 60d442f3f..a63f22b54 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3468,6 +3468,15 @@ impl Scene { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); + // A table's drawing (its *T block) follows the table's cells, sizes + // and styles; it is left alone while it already matches. + for (handle, kind) in &changes { + if !matches!(kind, ChangeKind::Removed) + && matches!(self.document.get_entity(*handle), Some(EntityType::Table(_))) + { + self.document.refresh_table_block(*handle); + } + } // A restyle (layer on/off, colour, text or dimension style) moves no // geometry, so nothing measured from it is re-resolved: an // association that disagrees with its dimension would otherwise diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index f444a2266..1f31eeb31 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -153,6 +153,11 @@ pub struct CountPalette { pub color: i16, pub error_color: i16, pub service: bool, + /// The Invalid Area dialog's "always" box while it is open. + pub invalid_always: bool, + /// What a lost count boundary does without asking: 0 ask, 1 undo, 2 go + /// on with all of model space (kept with the user settings). + pub invalid_choice: u8, /// The list's references for (document, geometry epoch, area). pub list_cache: std::cell::RefCell>, std::rc::Rc>)>>, } @@ -171,6 +176,8 @@ impl Default for CountPalette { color: 3, error_color: 1, service: true, + invalid_always: false, + invalid_choice: 0, list_cache: Default::default(), } } @@ -217,6 +224,12 @@ pub enum CountMsg { Select, Field, Close, + /// The Invalid Area dialog: undo the boundary change (also its ✕ and + /// Cancel), or go on counting all of model space; `InvalidAlways` is its + /// "always perform my current choice" box. + InvalidUndo, + InvalidContinue, + InvalidAlways(bool), } fn msg(m: CountMsg) -> Message { @@ -609,3 +622,57 @@ pub fn aci_rgba(index: i16) -> [f32; 4] { crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::from_index(index)) } + +/// The Invalid Area dialog, shown when the count area's boundary is gone. +pub fn invalid_area_view<'a>(palette: &CountPalette, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let action = |title: String, detail: String, m: CountMsg| -> Element<'a, Message> { + button( + row![ + text("→").size(16).style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) }), + column![text(title).size(13), text(detail).size(11).style(muted_style)].spacing(2), + ] + .spacing(10) + .align_y(iced::Center), + ) + .on_press(msg(m)) + .width(Fill) + .padding([10, 12]) + .style(button_style(false)) + .into() + }; + column![ + row![ + crate::ui::icons::themed_warning(WARNING_ICON, 20.0), + text(crate::t!("The changes to count boundary have resulted to an invalid count area. What do you want to do?")) + .size(14) + .width(Fill), + ] + .spacing(12) + .align_y(iced::Center), + action( + crate::t!("Undo the changes to the count boundary").into_owned(), + crate::t!("The last change is undone; the boundary and the count area come back.").into_owned(), + CountMsg::InvalidUndo, + ), + action( + crate::t!("Continue and count the entire model space").into_owned(), + crate::t!("The count area is dropped; counting goes on in all of model space.").into_owned(), + CountMsg::InvalidContinue, + ), + row![ + checkbox(palette.invalid_always) + .label(crate::t!("Always perform my current choice").into_owned()) + .text_size(12) + .size(14) + .on_toggle(|v| msg(CountMsg::InvalidAlways(v))), + Space::new().width(Fill), + crate::ui::style::form::dialog_button(crate::t!("Cancel"), msg(CountMsg::InvalidUndo), false), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(10) + .padding([12, 14]) + .width(sizing.width) + .into() +} From 9b5b9dc707798fe55118268d3d69edd1bb0dd69a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:47:29 +0300 Subject: [PATCH 10/11] Cargo: codec 6d3ecf4 (hatch copies along their curves, merged table cells) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40b4fb9d5..db9e9d6c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=115b753#115b7536c7c00bdd9684a80f2bb7642c11b048e9" +source = "git+https://github.com/ramox81/cadcodec.git?rev=6d3ecf4#6d3ecf4962eb2e8a1ddcefb72329536d0efb9cc8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a1dedd3f3..e8cd768d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "115b753" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6d3ecf4" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } From 9ad569d33241820b1a64e2566872bb7807e2d197 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:10:39 +0300 Subject: [PATCH 11/11] Cargo: codec 196a5ae (mirrored pattern hatch is not a copy) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db9e9d6c7..424ec3f19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=6d3ecf4#6d3ecf4962eb2e8a1ddcefb72329536d0efb9cc8" +source = "git+https://github.com/ramox81/cadcodec.git?rev=196a5ae#196a5aeabc1f22a526259d6788efcf0df9ad24e9" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index e8cd768d4..3f5b6ce85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6d3ecf4" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "196a5ae" } [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }