diff --git a/Cargo.lock b/Cargo.lock index b23557d69..ff7490848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=380e105#380e1057426ebd621f9967a06df97d54dce4c983" +source = "git+https://github.com/ramox81/cadcodec.git?rev=43f83ed#43f83ed5876684b906e01339d8aef617905845c8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 43f45e7f6..5629a28a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,4 +164,4 @@ name = "command_benchmarks" harness = false [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "380e105" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "43f83ed" } diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs index 53f9b6ab0..3dbfe5a14 100644 --- a/src/app/commands/pc_extract.rs +++ b/src/app/commands/pc_extract.rs @@ -39,7 +39,7 @@ impl OpenCADStudio { data, color, None, - &crate::scene::model::point_cloud::hidden(common), + &crate::scene::model::point_cloud::hidden(data), ) { clouds.push((common.handle, placed)); } diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index dcd5f839c..5d1a98bd6 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -162,11 +162,11 @@ impl OpenCADStudio { self.refresh_properties(); } - /// A Point Cloud Manager click: a switch writes the cloud's visibility - /// extended data as one undo step; the rest is palette state. + /// A Point Cloud Manager click: a switch writes the cloud's hidden scans + /// and regions as one undo step; the rest is palette state. pub(in crate::app) fn update_pc_manager(&mut self, message: crate::ui::window::pc_manager::PcManagerMsg) -> Task { - use crate::scene::model::point_cloud::VISIBILITY_APP; - use crate::ui::window::pc_manager::{self, PcManagerMsg}; + use crate::scene::model::point_cloud::UNASSIGNED_OFF; + use crate::ui::window::pc_manager::{self, PcManagerMsg, Row}; let i = self.active_tab; match message { PcManagerMsg::Toggle(handle, row) => { @@ -178,22 +178,19 @@ impl OpenCADStudio { if self.tabs[i].scene.is_layer_locked(handle) { return Task::none(); } + // A scan named by its file name (automation) is the scan with that name. + let row = match row { + Row::Scan(key) => Row::Scan( + cloud.scans.iter().find(|(name, _)| *name == key).map_or(key, |(_, id)| id.clone()), + ), + row => row, + }; let hidden = pc_manager::toggled(&cloud.hidden, &cloud.scans, &row); self.push_undo_snapshot(i, "POINTCLOUDMANAGER"); - self.tabs[i].scene.ensure_app_id(VISIBILITY_APP); - let app_handle = self.tabs[i].scene.document.app_ids.get(VISIBILITY_APP).map(|a| a.handle.value()); - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - let data = &mut entity.common_mut().extended_data; - if let Some(app) = app_handle { - data.raw_dwg_eed.retain(|(a, _)| *a != app); - } - data.remove_record(VISIBILITY_APP); - if !hidden.is_empty() { - let mut record = codec::xdata::ExtendedDataRecord::new(VISIBILITY_APP); - for name in hidden { - record.add_value(codec::xdata::XDataValue::String(name)); - } - data.add_record(record); + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.hidden_regions = if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0] } else { Vec::new() }; + data.hidden_scans = hidden.into_iter().filter(|h| h != UNASSIGNED_OFF).collect(); } } self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs index e7440c4cb..6f1ef62a0 100644 --- a/src/app/commands/point_cloud.rs +++ b/src/app/commands/point_cloud.rs @@ -146,8 +146,8 @@ impl OpenCADStudio { name, show_intensity: false, show_cropping: true, - unknown_bl0: 0, - unknown_bl1: 0, + hidden_scans: Vec::new(), + hidden_regions: Vec::new(), stylization_type: 1, intensity_color_scheme: String::new(), current_color_scheme: String::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index cc15ad6c4..a954c665c 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -570,7 +570,8 @@ fn point_cloud_wires( let mut shown = true; let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new()); if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { - shown = crate::scene::model::point_cloud::resolve_source(document, data).is_none(); + // Its box shows too with every point hidden (the reference does). + shown = crate::scene::model::point_cloud::shows_no_points(document, data); let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut tris = Vec::with_capacity(36); for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d5ae9db30..203bd8a55 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8408,7 +8408,7 @@ impl Scene { data, object_color, transform, - &point_cloud::hidden(common), + &point_cloud::hidden(data), )); }, ); diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6da5f4b7c..1fc84634f 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -32,7 +32,7 @@ pub struct CloudPoints { /// Scans read (one for a scan file). pub scans: usize, /// Each scan's name (its file stem) and its points' index range. - pub scan_ranges: Vec<(String, std::ops::Range)>, + pub scan_ranges: Vec, /// A project's preview picture (JPEG). pub preview: Option>, /// Bounds from the scans' headers, placed like their points: the @@ -73,18 +73,21 @@ fn decode(path: &Path) -> Option { // A project lists its scans with their own transforms; a scan file // carries the same transform in its header. let stem = |p: &Path| p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned()); - let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String)> = if is_project { + let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String, String)> = if is_project { rcp::scans(&bytes, path)? .into_iter() .filter_map(|entry| { let scan = rcs::decode(&std::fs::read(&entry.path).ok()?)?; - Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path))) + // The project's identifier, else the scan file's own. + let id = if entry.id.is_empty() { scan.id.clone() } else { entry.id.clone() }; + Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path), id)) }) .collect() } else { let scan = rcs::decode(&bytes)?; let transform = [scan.translation, scan.rotation, scan.scale]; - vec![(scan, transform, stem(path))] + let id = scan.id.clone(); + vec![(scan, transform, stem(path), id)] }; let mut cloud = CloudPoints { id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), @@ -100,7 +103,7 @@ fn decode(path: &Path) -> Option { preview: is_project.then(|| rcp::preview(&bytes)).flatten(), bounds: None, }; - for (scan, [translation, rotation, scale], name) in scans { + for (scan, [translation, rotation, scale], name, id) in scans { let rotate = euler(rotation.map(f64::to_radians)); let place = |p: &[f64; 3]| { let s = [p[0] * scale[0], p[1] * scale[1], p[2] * scale[2]]; @@ -109,7 +112,8 @@ fn decode(path: &Path) -> Option { }; let first = cloud.positions.len(); cloud.positions.extend(scan.local.iter().map(place)); - cloud.scan_ranges.push((name, first..cloud.positions.len())); + let id = if id.is_empty() { name.clone() } else { id }; + cloud.scan_ranges.push(ScanRange { name, id, range: first..cloud.positions.len() }); let [lo, hi] = scan.bounds; for corner in 0..8 { let p = place(&[ @@ -257,27 +261,37 @@ pub(crate) fn any_definition(document: &CadDocument) -> bool { }) } -/// What the Point Cloud Manager turned off on a cloud: extended data under -/// this application, one string per hidden scan plus the markers below. -pub const VISIBILITY_APP: &str = "OCS_POINTCLOUD_VISIBILITY"; -/// The whole cloud is off. -pub const CLOUD_OFF: &str = "*CLOUD_OFF"; -/// Its unassigned points (every point of a cloud without regions) are off. -pub const UNASSIGNED_OFF: &str = "*UNASSIGNED_OFF"; +/// One scan's points in a cloud: its name (the file's), its identifier and +/// its index range. +#[derive(Debug, Clone)] +pub struct ScanRange { + pub name: String, + pub id: String, + pub range: std::ops::Range, +} -/// The scans and markers a cloud entity hides. -pub(crate) fn hidden(common: &codec::entities::EntityCommon) -> Vec { - common - .extended_data - .records() - .iter() - .filter(|r| r.application_name.eq_ignore_ascii_case(VISIBILITY_APP)) - .flat_map(|r| r.values.iter()) - .filter_map(|v| match v { - codec::xdata::XDataValue::String(s) => Some(s.clone()), - _ => None, - }) - .collect() +/// Stands in a hidden list for the cloud's unassigned points (region 0), +/// which are every point of a cloud without regions. +pub const UNASSIGNED_OFF: &str = "*UNASSIGNED"; + +/// What a cloud turns off: the identifiers of its hidden scans, and +/// [`UNASSIGNED_OFF`] when its unassigned points are hidden. +pub(crate) fn hidden(data: &PointCloudExData) -> Vec { + let mut hidden = data.hidden_scans.clone(); + if data.hidden_regions.contains(&0) { + hidden.push(UNASSIGNED_OFF.to_string()); + } + hidden +} + +/// Whether a cloud draws none of its points: its file is not found, or its +/// unassigned points or every scan are turned off. +pub(crate) fn shows_no_points(document: &CadDocument, data: &PointCloudExData) -> bool { + let Some(cloud) = resolve_source(document, data).and_then(|path| load(&path)) else { + return true; + }; + data.hidden_regions.contains(&0) + || (!cloud.scan_ranges.is_empty() && cloud.scan_ranges.iter().all(|s| data.hidden_scans.contains(&s.id))) } /// The points `data` shows, placed through its origin and axes and then @@ -292,10 +306,10 @@ pub(crate) fn placed( ) -> Option> { let cloud = load(&resolve_source(document, data)?)?; let skipped: Vec> = - if hidden.iter().any(|h| h == CLOUD_OFF || h == UNASSIGNED_OFF) { + if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0..cloud.positions.len()] } else { - cloud.scan_ranges.iter().filter(|(name, _)| hidden.contains(name)).map(|(_, r)| r.clone()).collect() + cloud.scan_ranges.iter().filter(|s| hidden.contains(&s.id)).map(|s| s.range.clone()).collect() }; // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, // in tenths by POINTCLOUDLOD, every n-th point of a larger cloud. diff --git a/src/scene/model/point_cloud/rcp.rs b/src/scene/model/point_cloud/rcp.rs index 89f1982c5..74ec187e3 100644 --- a/src/scene/model/point_cloud/rcp.rs +++ b/src/scene/model/point_cloud/rcp.rs @@ -9,6 +9,8 @@ use std::path::{Path, PathBuf}; /// One visible scan of the project. pub struct ScanEntry { pub path: PathBuf, + /// The scan's identifier ("{…}"). + pub id: String, pub translation: [f64; 3], pub rotation: [f64; 3], pub scale: [f64; 3], @@ -90,6 +92,7 @@ fn entry(node: roxmltree::Node, folder: &Path) -> Option { })?; Some(ScanEntry { path, + id: node.attribute("Id").unwrap_or_default().to_string(), translation: xyz(node, "Translation", 0.0), rotation: xyz(node, "Rotation", 0.0), scale: xyz(node, "Scale", 1.0), diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs index a49f10a5c..bbd77bbaf 100644 --- a/src/scene/model/point_cloud/rcs.rs +++ b/src/scene/model/point_cloud/rcs.rs @@ -32,6 +32,9 @@ pub struct Scan { pub scale: [f64; 3], /// The scan's bounds as its header records them (local frame). pub bounds: [[f64; 3]; 2], + /// The scan's identifier ("{…}"), as its project and a cloud's hidden + /// scans name it. + pub id: String, } const NODE_RECORD: usize = 368; @@ -77,6 +80,16 @@ fn normal(code: u16) -> [f32; 3] { n.map(|v| (v / length) as f32) } +/// The identifier the header carries after its flags, NUL-terminated. +fn header_id(b: &[u8]) -> String { + let tail = b.get(0xE3..b.len().min(0xE3 + 64)).unwrap_or_default(); + let text = &tail[..tail.iter().position(|c| *c == 0).unwrap_or(tail.len())]; + match std::str::from_utf8(text) { + Ok(id) if id.starts_with('{') && id.ends_with('}') => id.to_string(), + _ => String::new(), + } +} + pub fn decode(b: &[u8]) -> Option { if b.get(0..5)? != b"ADOCT" { return None; @@ -119,6 +132,7 @@ pub fn decode(b: &[u8]) -> Option { rotation, scale, bounds, + id: header_id(b), }; for node in 0..nodes { let record = directory + 8 + node * NODE_RECORD; diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs index 75f1bf6fa..4d7e4ae74 100644 --- a/src/ui/window/pc_manager.rs +++ b/src/ui/window/pc_manager.rs @@ -1,10 +1,10 @@ //! Point Cloud Manager (POINTCLOUDMANAGER): the regions and scans of the //! drawing's point clouds as a tree, each with a switch that shows or hides -//! its points. The switches are the cloud's own visibility extended data, so +//! its points. The switches are the cloud's own hidden scans and regions, so //! the tree is read straight from the document on every view. use crate::app::Message; -use crate::scene::model::point_cloud::{self, CLOUD_OFF, UNASSIGNED_OFF}; +use crate::scene::model::point_cloud::{self, UNASSIGNED_OFF}; use crate::ui::dock::PanelId; use codec::entities::ExtendedEntityData; use codec::{CadDocument, EntityType, Handle}; @@ -32,11 +32,21 @@ pub enum Row { Cloud, Unassigned, Scans, + /// A scan, by identifier. Scan(String), } +/// A switch's state: a parent is mixed while some children are on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Switch { + On, + Off, + Mixed, +} + impl Row { - /// `cloud`, `unassigned`, `scans` or `scan:` (automation). + /// `cloud`, `unassigned`, `scans` or `scan:` + /// (automation). pub fn parse(text: &str) -> Option { match text { "cloud" => Some(Self::Cloud), @@ -62,7 +72,8 @@ pub struct Cloud { pub handle: Handle, pub name: String, pub hidden: Vec, - pub scans: Vec, + /// Each scan's name and identifier. + pub scans: Vec<(String, String)>, } /// The drawing's point clouds; only the selected ones when any is selected. @@ -79,12 +90,12 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { let common = entity.common(); let scans = point_cloud::resolve_source(document, data) .and_then(|path| point_cloud::load(&path)) - .map(|cloud| cloud.scan_ranges.iter().map(|(name, _)| name.clone()).collect()) + .map(|cloud| cloud.scan_ranges.iter().map(|s| (s.name.clone(), s.id.clone())).collect()) .unwrap_or_default(); Some(Cloud { handle: common.handle, name: if data.name.is_empty() { format!("{:X}", common.handle.value()) } else { data.name.clone() }, - hidden: point_cloud::hidden(common), + hidden: point_cloud::hidden(data), scans, }) }) @@ -96,35 +107,48 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { } } -/// Whether a row's switch is on. A parent is on while any child is. -pub fn is_on(hidden: &[String], scans: &[String], row: &Row) -> bool { - let off = |name: &str| hidden.iter().any(|h| h == name); - let any_scan = scans.is_empty() || scans.iter().any(|s| !off(s)); +/// A row's switch. A parent is on with all its children on, off with all +/// off, mixed otherwise (the reference shows the cloud mixed with its only +/// scan off and its unassigned points on). +pub fn switch_state(hidden: &[String], scans: &[(String, String)], row: &Row) -> Switch { + let on = |id: &str| !hidden.iter().any(|h| h == id); + let of = |states: &[bool]| match (states.iter().all(|s| *s), states.iter().any(|s| *s)) { + (true, _) => Switch::On, + (false, false) => Switch::Off, + _ => Switch::Mixed, + }; + let scan_states: Vec = scans.iter().map(|(_, id)| on(id)).collect(); match row { - Row::Scan(name) => !off(name), - Row::Scans => any_scan, - Row::Unassigned => !off(UNASSIGNED_OFF), - Row::Cloud => !off(CLOUD_OFF) && (!off(UNASSIGNED_OFF) || any_scan), + Row::Scan(id) => of(&[on(id)]), + Row::Scans => of(&scan_states), + Row::Unassigned => of(&[on(UNASSIGNED_OFF)]), + Row::Cloud => { + let mut all = scan_states; + all.push(on(UNASSIGNED_OFF)); + of(&all) + } } } -/// The hidden list after flipping `row`. Switching the cloud back on shows -/// everything under it. -pub fn toggled(hidden: &[String], scans: &[String], row: &Row) -> Vec { - fn set(out: &mut Vec, name: &str, off: bool) { - out.retain(|h| h != name); +/// The hidden list after clicking `row`: an on switch turns its rows off, +/// an off or mixed one turns them all on. +pub fn toggled(hidden: &[String], scans: &[(String, String)], row: &Row) -> Vec { + fn set(out: &mut Vec, id: &str, off: bool) { + out.retain(|h| h != id); if off { - out.push(name.to_string()); + out.push(id.to_string()); } } - let on = is_on(hidden, scans, row); + let off = switch_state(hidden, scans, row) == Switch::On; let mut out = hidden.to_vec(); match row { - Row::Cloud if on => set(&mut out, CLOUD_OFF, true), - Row::Cloud => out.clear(), - Row::Unassigned => set(&mut out, UNASSIGNED_OFF, on), - Row::Scans => scans.iter().for_each(|s| set(&mut out, s, on)), - Row::Scan(name) => set(&mut out, name, on), + Row::Cloud => { + scans.iter().for_each(|(_, id)| set(&mut out, id, off)); + set(&mut out, UNASSIGNED_OFF, off); + } + Row::Unassigned => set(&mut out, UNASSIGNED_OFF, off), + Row::Scans => scans.iter().for_each(|(_, id)| set(&mut out, id, off)), + Row::Scan(id) => set(&mut out, id, off), } out } @@ -151,7 +175,7 @@ fn tree_row<'a>( fold: Option, icon: Element<'a, Message>, label: String, - switch: Option<(bool, Message)>, + switch: Option<(Switch, Message)>, ) -> Element<'a, Message> { let arrow: Element<'a, Message> = match fold { Some(open) => button(if open { @@ -169,7 +193,15 @@ fn tree_row<'a>( let label = text(label).size(12).width(Fill); let label = if enabled { label } else { label.style(crate::ui::style::common::muted_style) }; let switch: Element<'a, Message> = match switch { - Some((on, message)) => checkbox(on).on_toggle(move |_| message.clone()).size(14).into(), + // Mixed: a dash in the box, as the reference's indeterminate state. + Some((Switch::Mixed, message)) => button(crate::ui::icons::themed_primary(crate::ui::icons::MINUS, 10.0)) + .on_press(message) + .style(button::secondary) + .padding(1) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .into(), + Some((on, message)) => checkbox(on == Switch::On).on_toggle(move |_| message.clone()).size(14).into(), None => checkbox(false).size(14).into(), }; let selected = state.selected.as_deref() == Some(key.as_str()); @@ -218,14 +250,14 @@ pub fn view<'a>( let mut tree = column![].spacing(1); for cloud in clouds(document, selected) { let root = key(cloud.handle); - let scans: Vec<&String> = cloud.scans.iter().filter(|s| matches(s)).collect(); + let scans: Vec<&(String, String)> = cloud.scans.iter().filter(|(name, _)| matches(name)).collect(); let show_scans = matches(&scans_label) || !scans.is_empty(); let children = matches(®ions) || matches(&unassigned) || show_scans; if !matches(&cloud.name) && !children { continue; } let switch = |row: Row| { - let on = is_on(&cloud.hidden, &cloud.scans, &row); + let on = switch_state(&cloud.hidden, &cloud.scans, &row); Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row)))) }; let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0); @@ -277,15 +309,15 @@ pub fn view<'a>( switch(Row::Scans), )); if scans_open { - for name in scans { + for (name, id) in scans { tree = tree.push(tree_row( state, - format!("{scans_key}/{name}"), + format!("{scans_key}/{id}"), 2, None, cloud_icon(), name.clone(), - switch(Row::Scan(name.clone())), + switch(Row::Scan(id.clone())), )); } }