graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: loader merges template evolution into saved instances
Saved scenes gain controls their templates grew after the save. Every
deserialized project routes through merge_template_defs (file load — which
the detached-window sync reload rides —, startup, and the thumbnail
renderer): params missing from an instance are appended with template
defaults, params it has keep their value but take the template's UI
metadata, and subnet templates (Sphere, Plane, Extrude) refresh their
matched children's Code outright — the template owns the surface and the
implementation, the instance owns its values. A kernel hand-edited inside a
template instance therefore reverts on load; custom kernels belong in bare
OpenCL nodes, whose Code is instance-owned and never touched.
Matching is conservative: native nodes match by type (rename-proof), subnet
instances by name ("Sphere 3" → "Sphere") and only when every template
child is present by name and type — lookalike subnets are left alone, and
nothing is injected or deleted. Simnet chains are out of scope by
construction (native type). The merge is load-time only: saved files are
not rewritten until the user saves.
src/app.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++-
src/main.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/project.rs | 6 ++--
src/thumbnail.rs | 6 +++-
4 files changed, 197 insertions(+), 4 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 9e47f5a..a6942e9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -295,6 +295,92 @@ pub fn flatten_node_templates(root: &FsNode) -> Vec<NodeTemplate> {
out
}
+/// Merge template evolution into a loaded project tree, so saved scenes gain
+/// controls added to a template after they were saved. Every deserialized
+/// project routes through this (file load, the detached-window sync reload,
+/// the thumbnail renderer).
+///
+/// The ownership rule: **the template owns the surface and the
+/// implementation, the instance owns its values.** Per matched node, params
+/// missing from the instance are appended with template defaults; params the
+/// instance has keep their value but take the template's UI metadata (type,
+/// label, range, options). For subnet templates (type "node" with children —
+/// Sphere, Plane, Extrude), the matched children's `Code` is refreshed from
+/// the template outright, because the new params are dead weight without the
+/// kernel that reads them — which means a kernel hand-edited INSIDE a
+/// template instance reverts on load; a custom kernel belongs in a bare
+/// OpenCL node, whose Code is instance-owned and never touched here.
+///
+/// Matching is conservative: native nodes (group, attribute, scatter, …)
+/// match their template by node type exactly; subnet instances match by name
+/// ("Sphere 3" → "Sphere", so a renamed instance simply keeps its saved
+/// shape), and only merge when EVERY template child is present by name and
+/// type — a hand-built subnet that happens to share the name is left alone,
+/// and nothing is ever injected or deleted. Simnet children (the user's sim
+/// chain) are out of scope by construction: simnet is a native type.
+pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
+ fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
+ if node.node_type.eq_ignore_ascii_case("node") {
+ let base = node.name.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end();
+ templates.iter().map(|t| &t.node).find(|t| {
+ t.node_type.eq_ignore_ascii_case("node")
+ && (t.name == node.name || (!base.is_empty() && t.name == base))
+ })
+ } else {
+ templates.iter().map(|t| &t.node).find(|t| {
+ !t.node_type.eq_ignore_ascii_case("node")
+ && t.node_type.eq_ignore_ascii_case(&node.node_type)
+ })
+ }
+ }
+ fn merge_params(node: &mut FsNode, template: &FsNode) {
+ for tp in &template.params {
+ if let Some(ip) = node.params.iter_mut().find(|p| p.name == tp.name) {
+ ip.param_type = tp.param_type.clone();
+ ip.label = tp.label.clone();
+ ip.options = tp.options.clone();
+ ip.min = tp.min;
+ ip.max = tp.max;
+ ip.step = tp.step;
+ } else {
+ node.params.push(tp.clone());
+ }
+ }
+ }
+ fn merge_node(node: &mut FsNode, templates: &[NodeTemplate]) {
+ if let Some(t) = template_for(node, templates) {
+ let owns_impl = t.node_type.eq_ignore_ascii_case("node") && !t.children.is_empty();
+ let children_match = t.children.iter().all(|tc| {
+ node.children.iter().any(|ic| ic.name == tc.name && ic.node_type == tc.node_type)
+ });
+ if !owns_impl || children_match {
+ merge_params(node, t);
+ if owns_impl {
+ for tc in &t.children {
+ let ic = node
+ .children
+ .iter_mut()
+ .find(|ic| ic.name == tc.name && ic.node_type == tc.node_type)
+ .expect("children_match checked above");
+ if let Some(t_code) = tc.params.iter().find(|p| p.name == "Code") {
+ if let Some(i_code) = ic.params.iter_mut().find(|p| p.name == "Code") {
+ i_code.default = t_code.default.clone();
+ }
+ }
+ merge_params(ic, tc);
+ }
+ }
+ }
+ }
+ for c in &mut node.children {
+ merge_node(c, templates);
+ }
+ }
+ for c in &mut root.children {
+ merge_node(c, templates);
+ }
+}
+
pub fn load_fs_tree() -> FsNode {
let nodes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("nodes");
let mut children = Vec::new();
@@ -2771,7 +2857,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
let mut loaded_project = None;
if default_proj_path.exists() {
if let Ok(content) = fs::read_to_string(&default_proj_path) {
- if let Ok(proj) = serde_json::from_str::<Project>(&content) {
+ if let Ok(mut proj) = serde_json::from_str::<Project>(&content) {
+ merge_template_defs(&mut proj.root, &node_templates);
loaded_project = Some(proj);
}
}
diff --git a/src/main.rs b/src/main.rs
index 6205b8a..bdefc2c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1131,6 +1131,106 @@ mod tests {
assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
}
+ /// The loader's template merge: saved instances gain params their
+ /// template grew after the save (values they already hold are kept), a
+ /// subnet instance's kernel refreshes to the template's (so the new
+ /// params actually work), and non-template lookalikes are left alone.
+ #[test]
+ fn test_loader_merges_new_template_params() {
+ let templates_root = crate::app::load_fs_tree();
+ let templates = crate::app::flatten_node_templates(&templates_root);
+ let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+ let group_t = templates_root.children.iter().find(|t| t.name == "Group").unwrap();
+
+ // An "old save": a Sphere instance from before the construction
+ // controls — only Radius (with a user value), and a stale kernel.
+ let mut old_sphere = sphere_t.clone();
+ old_sphere.id = "s".to_string();
+ old_sphere.name = "Sphere 3".to_string();
+ for child in &mut old_sphere.children {
+ child.id = format!("{}_{}", old_sphere.id, child.name);
+ }
+ old_sphere.params.retain(|p| p.name == "Radius");
+ old_sphere.params[0].default = "0.70".to_string();
+ let opencl = old_sphere.children.iter_mut().find(|c| c.name == "opencl1").unwrap();
+ opencl.params.iter_mut().find(|p| p.name == "Code").unwrap().default =
+ "OLD KERNEL".to_string();
+
+ // An old Group missing a later-added param, with a kept value.
+ let mut old_group = group_t.clone();
+ old_group.id = "g".to_string();
+ old_group.name = "My Region".to_string(); // renamed: native nodes match by TYPE
+ old_group.params.retain(|p| p.name != "Highlight");
+ old_group.params.iter_mut().find(|p| p.name == "Center").unwrap().default =
+ "0.00:0.80:0.00".to_string();
+
+ // A hand-built subnet that happens to share the Sphere name.
+ let lookalike = FsNode {
+ id: "fake".to_string(),
+ name: "Sphere 9".to_string(),
+ node_type: "node".to_string(),
+ children: vec![],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ };
+
+ let mut root = FsNode {
+ id: "root".to_string(),
+ name: "root".to_string(),
+ node_type: "node".to_string(),
+ children: vec![old_sphere, old_group, lookalike],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ };
+ crate::app::merge_template_defs(&mut root, &templates);
+
+ // Sphere: new params appended with template defaults, value kept,
+ // kernel refreshed.
+ let s = &root.children[0];
+ let names: Vec<&str> = s.params.iter().map(|p| p.name.as_str()).collect();
+ assert_eq!(names, ["Radius", "Rows", "Columns", "Center X", "Center Y", "Center Z"]);
+ assert_eq!(s.params[0].default, "0.70", "instance value survives");
+ let code = &s.children.iter().find(|c| c.name == "opencl1").unwrap()
+ .params.iter().find(|p| p.name == "Code").unwrap().default;
+ assert!(code.contains("chi(\"Rows\""), "kernel refreshed from template");
+
+ // And the merged instance evaluates with the new controls live.
+ let mut merged_sphere_root = root.clone();
+ merged_sphere_root.children.truncate(1);
+ merged_sphere_root.children[0].params.iter_mut()
+ .find(|p| p.name == "Rows").unwrap().default = "4".to_string();
+ merged_sphere_root.children[0].params.iter_mut()
+ .find(|p| p.name == "Columns").unwrap().default = "6".to_string();
+ let mut visited = Vec::new();
+ let mut err = None;
+ let mut cache = crate::geometry::SimCache::default();
+ let geom = crate::geometry::generate_single_node_geometry_with_errors(
+ &merged_sphere_root,
+ &merged_sphere_root.children[0],
+ &mut visited,
+ &mut err,
+ &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+ ).expect("merged sphere evaluates");
+ assert!(err.is_none(), "{err:?}");
+ assert_eq!(geom.vertices.len(), 4 * 6 * 6);
+
+ // Group (renamed, matched by type): Highlight restored, value kept.
+ let g = &root.children[1];
+ assert!(g.params.iter().any(|p| p.name == "Highlight" && p.default == "true"));
+ assert_eq!(g.params.iter().find(|p| p.name == "Center").unwrap().default, "0.00:0.80:0.00");
+
+ // Lookalike: untouched — no params gained, no children injected.
+ let l = &root.children[2];
+ assert!(l.params.is_empty());
+ assert!(l.children.is_empty());
+ }
+
/// The Sphere template's construction controls: Rows/Columns set the
/// lat/lon tessellation (vertex count = rows * columns * 6), Center X/Y/Z
/// place the sphere, and the defaults keep the historical 16x24 sphere at
diff --git a/src/project.rs b/src/project.rs
index 55c37c2..167bf2b 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -127,7 +127,8 @@ impl State {
pub(crate) fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if path.file_name().map_or(false, |n| n == "default_project.json") {
let content = fs::read_to_string(path)?;
- let proj: Project = serde_json::from_str(&content)?;
+ let mut proj: Project = serde_json::from_str(&content)?;
+ crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
self.fs_root = proj.root;
self.ensure_menubar_subnets();
self.apply_settings_from_menubar_subnets();
@@ -181,7 +182,8 @@ impl State {
};
let content = fs::read_to_string(&state_file_path)?;
- let proj: Project = serde_json::from_str(&content)?;
+ let mut proj: Project = serde_json::from_str(&content)?;
+ crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
self.fs_root = proj.root;
self.ensure_menubar_subnets();
self.apply_settings_from_menubar_subnets();
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 230e02d..a3ec2ac 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -23,8 +23,12 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>) -> Resul
let state_file = if project.is_dir() { project.join("state.json") } else { project.to_path_buf() };
let content = std::fs::read_to_string(&state_file)
.map_err(|e| format!("read {}: {e}", state_file.display()))?;
- let proj: Project =
+ let mut proj: Project =
serde_json::from_str(&content).map_err(|e| format!("parse {}: {e}", state_file.display()))?;
+ // Same template merge the app applies on load, so a thumbnail of an old
+ // scene shows what opening it would show.
+ let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
+ crate::app::merge_template_defs(&mut proj.root, &templates);
let mut ocl_error = None;
// A headless thumbnail has no timeline: simnets render at their seed.