From 857f2f2e58e68c6c3458dd05fa8934d626e6e7e0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 25 Sep 2026 08:14:56 +0300 Subject: [PATCH] feat(graph): save and open node graphs as .ocg files The graph panel gets New, Open and Save. A .ocg file is JSON holding the graph (nodes, parameters, links), the canvas (positions, open sections, pan, zoom) and each object node's entity handles, plus the fingerprint GUID of the drawing those entities live in. On open, handles are reused only in that same drawing and only while the entity is still the node's kind; otherwise evaluation creates new entities, so a graph opened in another drawing never takes over unrelated objects. A drawing without a fingerprint gets one when its graph is first saved. Pins opencadcodec d6d534f (DXF now writes $FINGERPRINTGUID and $VERSIONGUID), opencadkernel 5d9e921 and opencadgraph 5c9b004 (graph JSON round trip). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 +-- Cargo.toml | 8 +-- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- src/app/control/mod.rs | 1 + src/app/control/sheets.rs | 2 +- src/app/node_graph.rs | 77 +++++++++++++++++++++ src/ui/node_graph.rs | 109 +++++++++++++++++++++++++++++- 9 files changed, 197 insertions(+), 16 deletions(-) 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);