diff --git a/Cargo.lock b/Cargo.lock index e1519e3c9..6295effe8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=43a82f9#43a82f9b09ab5250fbe6a03e49c1d5a73a4152f3" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d6d534f#d6d534fcfec5823e47ef5630668d8e16d62c1641" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4224,7 +4224,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=0d84557#0d84557a5e8b9320272927b8a39433e3338e0473" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=5c9b004#5c9b004a2fd10b9eb62b8d54ce443120ee417a95" dependencies = [ "opencadkernel", "serde_json", @@ -4233,7 +4233,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=5e24ef7#5e24ef7360fbb676c795e32f020114b3d36d3451" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=5d9e921#5d9e92104830444ca48ac7c5a8cdcfa24c21786b" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=5e24ef7#5e24ef7360fbb676c795e32f020114b3d36d3451" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=5d9e921#5d9e92104830444ca48ac7c5a8cdcfa24c21786b" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 3d0ae3730..9d4b1f7a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "43a82f9", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "5e24ef7", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "5e24ef7" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "0d84557" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d6d534f", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "5d9e921", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "5d9e921" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "5c9b004" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 4265dcce4..022c95c09 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "43a82f9", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d6d534f", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "43a82f9", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d6d534f", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index ad0c2cc78..fd592806b 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "43a82f9", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d6d534f", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index c2ef5c6fc..dcca42de7 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=43a82f9#43a82f9b09ab5250fbe6a03e49c1d5a73a4152f3" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d6d534f#d6d534fcfec5823e47ef5630668d8e16d62c1641" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index b14d4b229..f258ca447 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1317,6 +1317,7 @@ mod entities; pub(crate) mod http_bridge; mod interactive; mod sheets; +pub(crate) use sheets::new_guid_v4; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index 77d7f0ed7..6e876da7e 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -519,7 +519,7 @@ fn marker_identity(document: &codec::CadDocument, handle: codec::Handle) -> Opti } /// A random RFC 4122 version-4 GUID string, from the OS entropy pool. -fn new_guid_v4() -> String { +pub(crate) fn new_guid_v4() -> String { use std::fmt::Write as _; let mut bytes = [0u8; 16]; getrandom::fill(&mut bytes).expect("OS entropy unavailable"); diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index d1e8e1d3e..31c7be7d0 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -219,6 +219,83 @@ impl OpenCADStudio { return self.graph_set(i, port, value); } GraphMsg::Set(port, value) => return self.graph_set(i, port, value), + GraphMsg::New => self.tabs[i].graph = Default::default(), + GraphMsg::Save => { + // The file names the drawing by its fingerprint so reopening it + // elsewhere cannot take over unrelated entities; a drawing that + // never had one gets it now and saves it with its next save. + let tab = &mut self.tabs[i]; + let header = &mut tab.scene.document.header; + if header.fingerprint_guid.is_empty() { + header.fingerprint_guid = + format!("{{{}}}", crate::app::control::new_guid_v4().to_uppercase()); + tab.dirty = true; + } + let bytes = tab.graph.to_file(&tab.scene.document.header.fingerprint_guid); + return Task::perform( + async move { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Save Node Graph").as_ref()) + .add_filter(crate::t!("Node Graph").as_ref(), &["ocg"]) + .set_file_name("graph.ocg") + .save_file() + .await?; + let name = crate::sys::handle_path(&handle).to_string_lossy().into_owned(); + Some(handle.write(&bytes).await.map(|()| name).map_err(|e| e.to_string())) + }, + |result| Message::Graph(GraphMsg::Saved(result)), + ); + } + GraphMsg::Saved(Some(Ok(name))) => { + self.command_line + .push_output(crate::tf!("Node graph saved to {}", name).as_ref()); + } + GraphMsg::Saved(Some(Err(error))) => self.command_line.push_error(&error), + GraphMsg::Open => { + return Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Open Node Graph").as_ref()) + .add_filter(crate::t!("Node Graph").as_ref(), &["ocg"]) + .pick_file() + .await?; + let _ = crate::sys::handle_path(&handle); + Some(handle.read().await) + }, + |bytes| Message::Graph(GraphMsg::Loaded(bytes)), + ); + } + GraphMsg::Loaded(Some(bytes)) => { + let (mut graph, drawing) = match crate::ui::node_graph::Graph::from_file(&bytes) { + Ok(loaded) => loaded, + Err(error) => { + self.command_line.push_error(&error); + return Task::none(); + } + }; + // Entities are reused only in the drawing the graph was saved + // against, and only while they are still the node's kind; + // otherwise evaluation creates fresh ones. + let document = &self.tabs[i].scene.document; + let same_drawing = + !drawing.is_empty() && drawing == document.header.fingerprint_guid; + for node in &graph.engine.nodes { + let kind = match &node.kind { + Kind::Object(name) => ObjectKind::from_name(name), + Kind::Op(_) => None, + }; + if let Some(ui) = graph.ui.get_mut(&node.id) { + ui.handles.retain(|handle| { + same_drawing + && kind.zip(document.get_entity(*handle)).is_some_and( + |(kind, entity)| kind.matches(entity), + ) + }); + } + } + self.tabs[i].graph = graph; + return self.graph_run(i); + } _ => {} } Task::none() diff --git a/src/ui/node_graph.rs b/src/ui/node_graph.rs index 4faf68579..c738815d1 100644 --- a/src/ui/node_graph.rs +++ b/src/ui/node_graph.rs @@ -10,7 +10,7 @@ use codec::{EntityType, Handle}; use graph::{Kind, NodeId, Port, Spec}; use iced::widget::{ button, canvas, checkbox, column, container, mouse_area, opaque, pin, row, scrollable, slider, - stack, text, text_input, Space, + stack, text, text_input, tooltip, Space, }; use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, Widget}; @@ -20,7 +20,7 @@ use iced::{ Vector, }; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -use serde_json::Value; +use serde_json::{json, Value}; use crate::app::Message; use crate::t; @@ -85,6 +85,23 @@ impl ObjectKind { OBJECTS.iter().copied().find(|kind| kind.name() == name) } + /// Whether `entity` is this kind of object. + pub fn matches(self, entity: &EntityType) -> bool { + matches!( + (self, entity), + (ObjectKind::Line, EntityType::Line(_)) + | (ObjectKind::Circle, EntityType::Circle(_)) + | (ObjectKind::Arc, EntityType::Arc(_)) + | (ObjectKind::Ellipse, EntityType::Ellipse(_)) + | (ObjectKind::Point, EntityType::Point(_)) + | (ObjectKind::Polyline, EntityType::LwPolyline(_)) + | (ObjectKind::Ray, EntityType::Ray(_)) + | (ObjectKind::XLine, EntityType::XLine(_)) + | (ObjectKind::Text, EntityType::Text(_)) + | (ObjectKind::MText, EntityType::MText(_)) + ) + } + fn command(self) -> &'static str { match self { ObjectKind::Line => "LINE", @@ -224,6 +241,13 @@ pub enum GraphMsg { /// A slider or toggle set an operation input. Set(Port, Value), Zoom(mouse::ScrollDelta), + /// Start an empty graph; the drawing keeps the old graph's objects. + New, + Save, + /// `None` when the dialog was cancelled. + Saved(Option>), + Open, + Loaded(Option>), } fn msg(m: GraphMsg) -> Message { @@ -268,6 +292,66 @@ impl Graph { Point::new(self.pan.x + graph.x * zoom, self.pan.y + graph.y * zoom) } + /// The `.ocg` file: the graph, the canvas, and the fingerprint of the + /// drawing its object nodes' entities live in. + pub fn to_file(&self, drawing: &str) -> Vec { + let nodes: serde_json::Map = self + .ui + .iter() + .map(|(id, ui)| { + let mut open: Vec<&String> = ui.open.iter().collect(); + open.sort(); + let handles: Vec = ui.handles.iter().map(|handle| handle.value()).collect(); + (id.to_string(), json!({ "pos": [ui.pos.x, ui.pos.y], "open": open, "handles": handles })) + }) + .collect(); + let file = json!({ + "format": "ocg", + "version": 1, + "drawing": drawing, + "graph": self.engine.to_json(), + "canvas": { "pan": [self.pan.x, self.pan.y], "zoom": self.zoom_steps, "nodes": nodes }, + }); + serde_json::to_vec_pretty(&file).unwrap_or_default() + } + + /// Reads an `.ocg` file, returning the graph and the drawing fingerprint + /// it was saved against. Handles come back unchecked. + pub fn from_file(bytes: &[u8]) -> Result<(Self, String), String> { + let file: Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + if file["format"] != "ocg" { + return Err(t!("Not a node graph file").into_owned()); + } + let mut graph = Graph { + engine: graph::Graph::from_json(&file["graph"])?, + ..Graph::default() + }; + let canvas = &file["canvas"]; + let pair = |value: &Value| { + Some(Vector::new(value[0].as_f64()? as f32, value[1].as_f64()? as f32)) + }; + graph.pan = pair(&canvas["pan"]).unwrap_or_default(); + graph.zoom_steps = canvas["zoom"] + .as_i64() + .map_or(0, |steps| (steps as i32).clamp(*ZOOM_STEPS.start(), *ZOOM_STEPS.end())); + let ids: Vec = graph.engine.nodes.iter().map(|node| node.id).collect(); + for (index, id) in ids.into_iter().enumerate() { + let saved = &canvas["nodes"][id.to_string()]; + let step = (index % 6) as f32 * 30.0; + let pos = pair(&saved["pos"]).unwrap_or(Vector::new(TREE_W + 40.0 + step, 40.0 + step)); + let open: HashSet = match saved["open"].as_array() { + Some(titles) => titles.iter().filter_map(|title| title.as_str().map(str::to_owned)).collect(), + None => std::iter::once(t!("Geometry").into_owned()).collect(), + }; + let handles = saved["handles"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_u64).map(Handle::new).collect()) + .unwrap_or_default(); + graph.ui.insert(id, NodeUi { pos: Point::ORIGIN + pos, open, handles }); + } + Ok((graph, file["drawing"].as_str().unwrap_or_default().to_owned())) + } + pub fn add_node(&mut self, item: PaletteItem, handles: Vec, pos: Point) -> NodeId { let kind = match item { PaletteItem::Object(kind) => Kind::Object(kind.name().to_owned()), @@ -485,7 +569,26 @@ impl Graph { } fn palette_view(&self) -> Element<'_, Message> { - let mut col = column![text(t!("Nodes")).size(12)].spacing(2).padding(8); + let tool = |icon: &'static [u8], tip: &str, message: GraphMsg| { + tooltip( + button(crate::ui::icons::themed(icon, 12.0)) + .on_press(msg(message)) + .style(button::text) + .padding([2, 4]), + container(text(t!(tip)).size(11)) + .padding([3, 6]) + .style(container::bordered_box), + tooltip::Position::Bottom, + ) + }; + let header = row![ + text(t!("Nodes")).size(12).width(Length::Fill), + tool(crate::ui::icons::DOC_NEW, "New graph", GraphMsg::New), + tool(crate::ui::icons::FOLDER_OPEN, "Open graph", GraphMsg::Open), + tool(crate::ui::icons::SAVE, "Save graph", GraphMsg::Save), + ] + .align_y(iced::Center); + let mut col = column![header].spacing(2).padding(8); let categories = std::iter::once("Objects").chain(graph::CATEGORIES.iter().copied()); for category in categories { let open = !self.closed_categories.contains(category);