Files
OneCAD-Draft/src/app/node_graph.rs
T
Hakan SevenandClaude Opus 5.5 3013c1b570 feat(graph): node library as a docked panel; shared dock chrome
The node library leaves the overlay and becomes a dock panel (PanelId::
NodeGraph), docked on the right the first time the graph opens and movable
like any other panel; closing it closes the graph. It follows the block
palette's layout: title bar, then a search box with New / Open / Save
beside it, then the categories, of which only Objects starts open. Entries
are clicked (placed at the canvas top-left) or dragged onto the canvas.

The title bar (title, auto-collapse pin, close, drag to re-dock), the
toolbar icon button and the panel frame were copied across Properties,
Block Palette, External References and Browser; they are now
dock::title_bar, dock::tool_button and dock::frame, used by all of them.
Block Palette's two tool buttons gain tooltips, and External References'
Auto / Close tooltips are now translated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 10:17:44 +03:00

421 lines
17 KiB
Rust

//! Node graph handling. An object node's rows are the Properties panel's own
//! page for its entity, and every write goes through the panel's handlers (via
//! the control protocol's `set_property_value`) aimed at that entity. The
//! `graph` crate evaluates; [`AppHost`] gives it the drawing.
use super::{Message, OpenCADStudio};
use crate::scene::model::object::PropSection;
use crate::ui::node_graph::{Drag, GraphMsg, NodeRow, NodeSection, ObjectKind, RowWidget};
use codec::Handle;
use graph::{Kind, NodeId, Port};
use iced::Task;
use rustc_hash::FxHashMap as HashMap;
use serde_json::{json, Value};
impl OpenCADStudio {
/// The Properties panel's single-object page for `handle`.
fn graph_panel_sections(&self, i: usize, handle: Handle) -> Vec<PropSection> {
let tab = &self.tabs[i];
let Some(entity) = tab.scene.document.get_entity(handle) else {
return Vec::new();
};
crate::entities::common::set_unit_context(crate::entities::common::UnitContext::from_header(
&tab.scene.document.header,
));
crate::entities::common::set_fixed_text_heights(&tab.scene.document);
let text_style_names: Vec<String> = tab
.scene
.document
.text_styles
.iter()
.map(|style| style.name.trim().to_string())
.filter(|name| !name.is_empty())
.collect();
self.entity_property_sections(
i,
handle,
entity,
&text_style_names,
tab.scene.displayed_annotation_scale_handle(),
0,
)
.0
}
/// Every row of `handle`'s panel page as `(field, protocol value)`.
fn graph_fields(&self, i: usize, handle: Handle) -> Vec<(String, Value)> {
self.graph_panel_sections(i, handle)
.iter()
.flat_map(|section| &section.props)
.map(|property| {
let json = crate::app::control::property_json(property);
(property.field.to_owned(), json["value"].clone())
})
.collect()
}
/// An object node's rows: its object handle(s), then the panel page of its
/// first entity. With several entities the values shown are the lists.
fn graph_object_sections(&self, i: usize, id: NodeId) -> Vec<NodeSection> {
let graph = &self.tabs[i].graph;
let Some(&first) = graph.ui.get(&id).and_then(|ui| ui.handles.first()) else {
return Vec::new();
};
let outputs = graph.engine.node(id).map(|node| &node.outputs);
let listed = graph.ui.get(&id).is_some_and(|ui| ui.handles.len() > 1);
let value = |field: &str, own: Value| match outputs.and_then(|outputs| outputs.get(field)) {
Some(value) if listed => value.clone(),
_ => own,
};
let panel = self.graph_panel_sections(i, first);
if panel.is_empty() {
return Vec::new();
}
let object = NodeRow {
field: "object".to_owned(),
label: crate::t!("Object").into_owned(),
value: value("object", json!({ "handle": first.value() })),
input: false,
output: true,
widget: RowWidget::Field,
};
std::iter::once(NodeSection { title: String::new(), rows: vec![object] })
.chain(panel.into_iter().map(|section| NodeSection {
title: section.title,
rows: section
.props
.iter()
.map(|property| {
let json = crate::app::control::property_json(property);
NodeRow {
field: property.field.to_owned(),
label: property.label.clone(),
input: !matches!(
json["kind"].as_str(),
Some("readonly" | "specialized")
),
output: true,
value: value(property.field, json["value"].clone()),
widget: RowWidget::Field,
}
})
.collect(),
}))
.collect()
}
/// Every node's rows, parallel to the graph's node list.
pub(super) fn graph_all_sections(&self, i: usize) -> Vec<Vec<NodeSection>> {
let graph = &self.tabs[i].graph;
let objects: HashMap<NodeId, Vec<NodeSection>> = graph
.engine
.nodes
.iter()
.filter(|node| matches!(node.kind, Kind::Object(_)))
.map(|node| (node.id, self.graph_object_sections(i, node.id)))
.collect();
graph.sections(objects)
}
/// Write one Properties row of `handle` through the panel's handlers.
fn graph_write(&mut self, i: usize, handle: Handle, field: &str, value: &Value) -> Task<Message> {
// An empty target falls back to the drawing defaults in some handlers.
if self.tabs[i].scene.is_layer_locked(handle) {
return Task::none();
}
let Some(property) = self
.graph_panel_sections(i, handle)
.into_iter()
.flat_map(|section| section.props)
.find(|property| property.field == field)
else {
return Task::none();
};
self.property_target_override = Some(handle);
let result = self.set_property_value(property, value);
self.property_target_override = None;
result.unwrap_or_else(|error| {
self.command_line
.push_error(error["error"].as_str().unwrap_or_default());
Task::none()
})
}
/// Runs `edit`, then re-evaluates the graph, all as one undo step.
fn graph_step(
&mut self,
i: usize,
edit: impl FnOnce(&mut Self) -> Vec<Task<Message>>,
) -> Task<Message> {
self.push_undo_snapshot(i, "GRAPH");
self.graph_undo_open = true;
let mut tasks = edit(self);
let mut engine = std::mem::take(&mut self.tabs[i].graph.engine);
let mut host = AppHost { app: self, i, tasks: Vec::new() };
engine.evaluate(&mut host);
tasks.append(&mut host.tasks);
self.tabs[i].graph.engine = engine;
self.graph_undo_open = false;
Task::batch(tasks)
}
fn graph_run(&mut self, i: usize) -> Task<Message> {
self.graph_step(i, |_| Vec::new())
}
pub(super) fn on_graph(&mut self, message: GraphMsg) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].graph.update_ui(&message);
match message {
GraphMsg::Toggle => {
self.show_node_graph = !self.show_node_graph;
if self.show_node_graph {
// The node library docks on the right on first use; after
// that it stays wherever the user moved it.
let id = crate::ui::dock::PanelId::NodeGraph;
if self.dock.location(id).is_none() {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
return self.graph_run(i);
}
}
GraphMsg::PaletteRelease => {
if let Some(Drag::Palette(item)) = self.tabs[i].graph.drag.take() {
let pos = self.tabs[i].graph.drop_position(false);
self.tabs[i].graph.add_node(item, Vec::new(), pos);
return self.graph_run(i);
}
}
GraphMsg::Released => {
let sections = self.graph_all_sections(i);
match self.tabs[i].graph.drag.take() {
Some(Drag::Palette(item)) => {
let pos = self.tabs[i].graph.drop_position(true);
self.tabs[i].graph.add_node(item, Vec::new(), pos);
return self.graph_run(i);
}
Some(Drag::Link(from)) => {
if let Some(to) = self.tabs[i].graph.hit_input(&sections) {
self.tabs[i].graph.engine.connect(from, to);
}
// A link dropped on nothing was detached from an input.
return self.graph_run(i);
}
_ => {}
}
}
GraphMsg::NodeDelete(id) => {
let graph = &mut self.tabs[i].graph;
graph.engine.remove(id);
graph.drafts.retain(|port, _| port.node != id);
let handles = graph.ui.remove(&id).map(|ui| ui.handles).unwrap_or_default();
return self.graph_step(i, |app| {
let live: Vec<Handle> = handles
.into_iter()
.filter(|handle| app.tabs[i].scene.document.get_entity(*handle).is_some())
.collect();
if !live.is_empty() {
app.tabs[i].scene.erase_entities(&live);
app.tabs[i].dirty = true;
app.refresh_properties();
}
Vec::new()
});
}
GraphMsg::Commit(port) => {
let Some(raw) = self.tabs[i].graph.drafts.remove(&port) else {
return Task::none();
};
// Typed text is protocol JSON when it parses (numbers, true,
// {"rgb":[..]}), otherwise a plain string.
let value = serde_json::from_str(raw.trim()).unwrap_or(Value::String(raw));
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()
}
/// An edited input: an operation keeps it as a parameter; an object node
/// writes it to every entity it owns.
fn graph_set(&mut self, i: usize, port: Port, value: Value) -> Task<Message> {
let graph = &mut self.tabs[i].graph;
let Some(node) = graph.engine.node_mut(port.node) else {
return Task::none();
};
if let Kind::Op(_) = node.kind {
node.params.insert(port.field, value);
return self.graph_run(i);
}
let handles = graph.ui.get(&port.node).map(|ui| ui.handles.clone()).unwrap_or_default();
self.graph_step(i, |app| {
handles
.into_iter()
.map(|handle| app.graph_write(i, handle, &port.field, &value))
.collect()
})
}
}
/// The drawing as the graph sees it.
struct AppHost<'a> {
app: &'a mut OpenCADStudio,
i: usize,
tasks: Vec<Task<Message>>,
}
impl graph::Host for AppHost<'_> {
fn objects(
&mut self,
node: NodeId,
kind: &str,
instances: &[Vec<(String, Value)>],
) -> Vec<Vec<(String, Value)>> {
let (app, i) = (&mut *self.app, self.i);
let Some(kind) = ObjectKind::from_name(kind) else {
return Vec::new();
};
let mut handles: Vec<Handle> = app.tabs[i]
.graph
.ui
.get(&node)
.map(|ui| ui.handles.clone())
.unwrap_or_default();
handles.retain(|handle| app.tabs[i].scene.document.get_entity(*handle).is_some());
// New entities copy the node's first one, so edits made by hand to
// unlinked rows carry over to every element.
while handles.len() < instances.len() {
let mut entity = handles
.first()
.and_then(|handle| app.tabs[i].scene.document.get_entity(*handle))
.cloned()
.unwrap_or_else(|| kind.entity());
entity.common_mut().handle = Handle::NULL;
let Some(handle) = app.commit_entity_handle(entity) else {
break;
};
handles.push(handle);
app.tabs[i].dirty = true;
}
if handles.len() > instances.len() {
let extra = handles.split_off(instances.len());
app.tabs[i].scene.erase_entities(&extra);
app.tabs[i].dirty = true;
}
if let Some(ui) = app.tabs[i].graph.ui.get_mut(&node) {
ui.handles = handles.clone();
}
for (handle, fields) in handles.iter().zip(instances) {
for (field, value) in fields {
let current = app
.graph_fields(i, *handle)
.into_iter()
.find(|(name, _)| name == field)
.map(|(_, value)| value);
if current.as_ref() != Some(value) {
let task = app.graph_write(i, *handle, field, value);
self.tasks.push(task);
}
}
}
handles
.iter()
.map(|handle| {
let mut fields = app.graph_fields(i, *handle);
fields.push(("object".to_owned(), json!({ "handle": handle.value() })));
fields
})
.collect()
}
fn curve(&self, object: &Value) -> Option<kernel::space::PlanarCurve> {
let handle = Handle::new(object.get("handle")?.as_u64()?);
let entity = self.app.tabs[self.i].scene.document.get_entity(handle)?;
crate::entities::curve::entity_curve(entity)
}
fn expression(&self, text: &str) -> Option<f64> {
crate::app::expr_eval::eval_number(text)
}
}