graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the params pane separates groups of parameters
A template parameter may name a group; the pane draws a separator wherever
two shown rows belong to different groups, and under a node's leading input
wires without a template saying so. The group is template metadata, read
and never written. The shipped templates are grouped: filters, outputs,
display switches, mode-specific and settings runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 34 +++++++++++++++++++++++++++++
nodes/analysis.json | 3 ++-
nodes/attribute.json | 54 +++++++++++++++++++++++++++++++----------------
nodes/boolean.json | 2 +-
nodes/bounds.json | 2 +-
nodes/collision.json | 11 ++++++----
nodes/copy.json | 2 +-
nodes/curve.json | 4 ++--
nodes/deform.json | 2 +-
nodes/detangle.json | 10 ++++-----
nodes/develop.json | 2 +-
nodes/embryo.json | 39 ++++++++++++++++++++++------------
nodes/export.json | 2 +-
nodes/grid.json | 6 +++---
nodes/group.json | 33 +++++++++++++++++++----------
nodes/mold_shell.json | 2 +-
nodes/neighbour.json | 30 +++++++++++++++++---------
nodes/page.json | 6 +++---
nodes/page_grid.json | 4 ++--
nodes/page_shape.json | 28 ++++++++++++------------
nodes/page_text.json | 16 +++++++-------
nodes/plane.json | 12 +++++++----
nodes/points.json | 6 ++++--
nodes/polygon.json | 2 +-
nodes/relax.json | 21 ++++++++++++------
nodes/remesh.json | 18 ++++++++++------
nodes/scatter.json | 14 ++++++------
nodes/simnet.json | 3 ++-
nodes/soft_transform.json | 8 +++----
nodes/sphere.json | 24 ++++++++++++++-------
nodes/suture.json | 2 +-
nodes/time.json | 6 +++---
nodes/transfer.json | 12 +++++++----
nodes/visualize.json | 24 ++++++++++++++-------
nodes/wrangle.json | 3 ++-
src/app.rs | 50 +++++++++++++++++++++++++++++++++++++++++--
src/main.rs | 45 +++++++++++++++++++++++++++++++++++++--
src/param.rs | 13 ++++++++++++
38 files changed, 393 insertions(+), 162 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0f359c6..80e1f28 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -805,6 +805,40 @@ hands every instance its template's type along with the rest of the UI
metadata (`adopt_ui_from`, which re-parses), so an old save's
`"type": "text"` wire loads as `node` (`a_saved_text_wire_loads_as_a_node_wire`).
+### The params pane separates groups of parameters (since 2026-10-01)
+
+A template parameter may carry `"group": "<name>"`; `param_display` puts
+a separator row (cce-ui's `separator`, a hairline) wherever two rows it
+SHOWS belong to different groups (`param_group`). So a separator is only
+ever between two shown rows — never first, last or doubled — and a run
+whose rows are all hidden by `show_when` leaves no line behind. The
+LEADING run of wires (the node's inputs, at the top of its list) is a
+group of its own without a template saying so (`PARAM_INPUTS_GROUP`), so
+every node with an input has a line under it; a wire further down
+(Relax's Rest) is part of whatever run it is in. Rows with no group are
+the node's unnamed run, so a template names only the runs after the first.
+
+- **The group is the template's**, as the description is: `adopt_ui_from`
+ hands it to every instance, and it is read and never written, so a save
+ is byte-identical and `sim_solve_key` does not move.
+- **Groups follow the template's ORDER.** Reordering a template's
+ parameters would reorder no saved instance (the merge keeps an
+ instance's order), so groups are contiguous runs as the parameters
+ stand; a name may come back after another run (the Group node's mode
+ rows sit either side of Invert/Highlight) and simply draws a line each
+ time it changes.
+- **What the groups are**, by convention across the templates: a node's
+ point-group filter (`Group`) is `where`; an operator's settings, its
+ output naming, its display switches and its mode-specific rows are
+ runs of their own (the Attribute node: target, value, amount, where,
+ range…; Page Shape: geometry, fill, stroke).
+- **A separator names no parameter**: its key and value are empty, so the
+ pane's write-back and `param_row_at` find nothing by it, and it shifts
+ no index between the pane's rows and `param_display`'s, which both
+ include it.
+
+`the_params_pane_separates_groups_of_parameters` is the test.
+
### Conditional parameter rows
A `ParamDef` may carry `show_when`, a condition over its SIBLINGS' current
diff --git a/nodes/analysis.json b/nodes/analysis.json
index 17d19fa..e5bec57 100644
--- a/nodes/analysis.json
+++ b/nodes/analysis.json
@@ -28,7 +28,8 @@
"type": "group",
"default": "",
"show_when": "Source == Attribute",
- "description": "A point group limiting which points are measured. Empty means every point."
+ "description": "A point group limiting which points are measured. Empty means every point.",
+ "group": "where"
}
]
}
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 281ad2a..46cabea 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -34,28 +34,32 @@
"type": "choice:Constant,Attribute",
"default": "Constant",
"show_when": "Operation == Create|Modify",
- "description": "Where the value comes from: Constant uses the Value row for every point; Attribute reads each point's own value of From Attribute."
+ "description": "Where the value comes from: Constant uses the Value row for every point; Attribute reads each point's own value of From Attribute.",
+ "group": "value"
},
{
"name": "From Attribute",
"type": "attribute",
"default": "",
"show_when": "Operation == Create|Modify && Value From == Attribute",
- "description": "The point attribute each point's value is read from when Value From is Attribute: copied as it is when it is as wide as the result, a single number spread to every component, otherwise as many components as fit, the rest zero. Pos and Col name the position and the colour."
+ "description": "The point attribute each point's value is read from when Value From is Attribute: copied as it is when it is as wide as the result, a single number spread to every component, otherwise as many components as fit, the rest zero. Pos and Col name the position and the colour.",
+ "group": "value"
},
{
"name": "Value",
"type": "text",
"default": "1.00",
"show_when": "Operation == Create|Modify && Value From == Constant",
- "description": "The number or numbers written, separated by spaces, commas or colons. One number fills every component; otherwise the count must match the attribute's width."
+ "description": "The number or numbers written, separated by spaces, commas or colons. One number fills every component; otherwise the count must match the attribute's width.",
+ "group": "value"
},
{
"name": "Combine",
"type": "choice:Set,Add,Multiply",
"default": "Set",
"show_when": "Operation == Modify",
- "description": "How Value meets the current value: Set replaces it, Add adds to it, Multiply multiplies it, componentwise."
+ "description": "How Value meets the current value: Set replaces it, Add adds to it, Multiply multiplies it, componentwise.",
+ "group": "amount"
},
{
"name": "Strength",
@@ -65,98 +69,112 @@
"max": 2.0,
"step": 0.05,
"show_when": "Operation == Modify",
- "description": "How much of the change lands: 1 is the full change, 0 none, 2 twice as much. Scales the difference the node makes under every Combine."
+ "description": "How much of the change lands: 1 is the full change, 0 none, 2 twice as much. Scales the difference the node makes under every Combine.",
+ "group": "amount"
},
{
"name": "Scale By",
"type": "attribute",
"default": "",
"show_when": "Operation == Modify",
- "description": "A point attribute whose value weights the change per point, multiplied with Strength. Empty weights every point equally; a name that is not an attribute is an error."
+ "description": "A point attribute whose value weights the change per point, multiplied with Strength. Empty weights every point equally; a name that is not an attribute is an error.",
+ "group": "amount"
},
{
"name": "Per Frame",
"type": "toggle",
"default": "true",
"show_when": "Operation == Modify && Combine != Set",
- "description": "Inside a simulation, makes Add and Multiply a rate per frame, so the substep count does not change how far a frame moves. Off lands the full amount every substep."
+ "description": "Inside a simulation, makes Add and Multiply a rate per frame, so the substep count does not change how far a frame moves. Off lands the full amount every substep.",
+ "group": "amount"
},
{
"name": "Group",
"type": "group",
"default": "",
"show_when": "Operation != Promote",
- "description": "A point group limiting which points are changed or measured. Empty means every point."
+ "description": "A point group limiting which points are changed or measured. Empty means every point.",
+ "group": "where"
},
{
"name": "From Min",
"type": "float",
"default": "0.00",
"show_when": "Operation == Remap|Clip",
- "description": "Remap: the input value that maps to To Min. Clip: the lower bound values are clamped to."
+ "description": "Remap: the input value that maps to To Min. Clip: the lower bound values are clamped to.",
+ "group": "range"
},
{
"name": "From Max",
"type": "float",
"default": "1.00",
"show_when": "Operation == Remap|Clip",
- "description": "Remap: the input value that maps to To Max. Clip: the upper bound values are clamped to. Must differ from From Min for a remap."
+ "description": "Remap: the input value that maps to To Max. Clip: the upper bound values are clamped to. Must differ from From Min for a remap.",
+ "group": "range"
},
{
"name": "To Min",
"type": "float",
"default": "0.00",
"show_when": "Operation == Remap",
- "description": "The value that From Min maps to; values between map linearly, and values outside the From range extend past it."
+ "description": "The value that From Min maps to; values between map linearly, and values outside the From range extend past it.",
+ "group": "range"
},
{
"name": "To Max",
"type": "float",
"default": "1.00",
"show_when": "Operation == Remap|Normalize",
- "description": "Remap: the value that From Max maps to. Normalize: the value the chosen measure (sum, maximum or range) is scaled to hit."
+ "description": "Remap: the value that From Max maps to. Normalize: the value the chosen measure (sum, maximum or range) is scaled to hit.",
+ "group": "range"
},
{
"name": "Source B",
"type": "attribute",
"default": "",
"show_when": "Operation == Composite",
- "description": "The second point attribute combined into this one, point by point. A single-number Source B applies to every component."
+ "description": "The second point attribute combined into this one, point by point. A single-number Source B applies to every component.",
+ "group": "composite"
},
{
"name": "Combine Op",
"type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
"default": "Add",
"show_when": "Operation == Composite",
- "description": "How Source B is folded in, componentwise. Dot, Distance and Length produce one number written into every component."
+ "description": "How Source B is folded in, componentwise. Dot, Distance and Length produce one number written into every component.",
+ "group": "composite"
},
{
"name": "To Class",
"type": "choice:Detail,Point",
"default": "Detail",
"show_when": "Operation == Promote",
- "description": "Where the attribute is moved: Detail reduces the per-point values to one number for the whole geometry; Point copies a detail value onto every point."
+ "description": "Where the attribute is moved: Detail reduces the per-point values to one number for the whole geometry; Point copies a detail value onto every point.",
+ "group": "promote"
},
{
"name": "Method",
"type": "choice:Average,Sum,Minimum,Maximum,First",
"default": "Average",
"show_when": "Operation == Promote",
- "description": "How point values are reduced to one detail value when promoting to Detail: their average, sum, minimum, maximum or the first point's."
+ "description": "How point values are reduced to one detail value when promoting to Detail: their average, sum, minimum, maximum or the first point's.",
+ "group": "promote"
},
{
"name": "Target",
"type": "choice:Sum,Maximum,Range",
"default": "Maximum",
"show_when": "Operation == Normalize",
- "description": "What is measured before scaling: the values' sum, their maximum, or their range (max minus min). Every value is scaled so this measure equals To Max."
+ "description": "What is measured before scaling: the values' sum, their maximum, or their range (max minus min). Every value is scaled so this measure equals To Max.",
+ "group": "normalize"
},
{
"name": "Kind",
"type": "choice:Live,Derivative",
"default": "Live",
"show_when": "Operation == Create",
- "description": "Live keeps the attribute's values from one simulation step to the next; Derivative zeroes it at the start of every step, for values the chain rebuilds each step."
+ "description": "Live keeps the attribute's values from one simulation step to the next; Derivative zeroes it at the start of every step, for values the chain rebuilds each step.",
+ "group": "kind"
}
]
}
diff --git a/nodes/boolean.json b/nodes/boolean.json
index 003e83b..90748e8 100644
--- a/nodes/boolean.json
+++ b/nodes/boolean.json
@@ -7,6 +7,6 @@
{ "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is the first operand, the shape the second is combined with or cut from." },
{ "name": "With", "type": "node", "default": "", "description": "The node whose geometry is the second operand: joined for Union, kept in common for Intersect, or cut away for Subtract." },
{ "name": "Operation", "type": "choice:Union,Intersect,Subtract", "default": "Union", "description": "Union joins both shapes, Intersect keeps only where they overlap, and Subtract removes the With shape from the Input." },
- { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005, "description": "Grid spacing in world units of the distance fields both shapes are sampled on. Smaller gives finer, cleaner edges but costs more." }
+ { "name": "Voxel Size", "type": "slider", "default": "0.05", "min": 0.005, "max": 1.0, "step": 0.005, "description": "Grid spacing in world units of the distance fields both shapes are sampled on. Smaller gives finer, cleaner edges but costs more.", "group": "resolution" }
]
}
diff --git a/nodes/bounds.json b/nodes/bounds.json
index a27408f..52ad048 100644
--- a/nodes/bounds.json
+++ b/nodes/bounds.json
@@ -6,6 +6,6 @@
"params": [
{ "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is measured; its bounding box is added as detail attributes and the geometry passed on." },
{ "name": "Prefix", "type": "text", "default": "bounds", "description": "The start of the four detail attribute names written: <prefix>_min, _max, _size and _center. Empty writes nothing." },
- { "name": "Group", "type": "group", "default": "", "description": "A point group whose points alone are measured. Empty measures every point." }
+ { "name": "Group", "type": "group", "default": "", "description": "A point group whose points alone are measured. Empty measures every point.", "group": "where" }
]
}
diff --git a/nodes/collision.json b/nodes/collision.json
index 7bc10e7..22634f5 100644
--- a/nodes/collision.json
+++ b/nodes/collision.json
@@ -25,20 +25,23 @@
"name": "Element Type",
"type": "choice:Points,Primitives",
"default": "Points",
- "description": "Points tests each point. Primitives tests each primitive's centroid and groups the hit primitives and their points."
+ "description": "Points tests each point. Primitives tests each primitive's centroid and groups the hit primitives and their points.",
+ "group": "output"
},
- { "name": "Group Name", "default": "collisions", "type": "group", "description": "Name of the group written with the elements that hit. Also written as a primitive group when primitives are tested." },
+ { "name": "Group Name", "default": "collisions", "type": "group", "description": "Name of the group written with the elements that hit. Also written as a primitive group when primitives are tested.", "group": "output" },
{
"name": "Invert",
"type": "toggle",
"default": "false",
- "description": "Groups the elements that did NOT hit the collider instead of those that did."
+ "description": "Groups the elements that did NOT hit the collider instead of those that did.",
+ "group": "output"
},
{
"name": "Highlight",
"type": "toggle",
"default": "true",
- "description": "Tints the grouped points red so the contact shows in the viewport. Off leaves colours untouched."
+ "description": "Tints the grouped points red so the contact shows in the viewport. Off leaves colours untouched.",
+ "group": "display"
}
]
}
diff --git a/nodes/copy.json b/nodes/copy.json
index f5029f1..58477bb 100644
--- a/nodes/copy.json
+++ b/nodes/copy.json
@@ -9,6 +9,6 @@
{ "name": "Orient", "type": "choice:None,Normal", "default": "None", "description": "None keeps every copy upright. Normal turns each copy so its +Y axis follows the target surface's normal at that point." },
{ "name": "Scale", "type": "float", "default": "1.00", "description": "Uniform scale applied to every copy about its own origin before it is placed." },
{ "name": "Scale Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute on the target whose value multiplies Scale per copy, so a field can drive their size. Empty uses Scale alone." },
- { "name": "Group", "type": "group", "default": "", "description": "Point group on the target that receives copies; other points get none. Empty copies onto every target point." }
+ { "name": "Group", "type": "group", "default": "", "description": "Point group on the target that receives copies; other points get none. Empty copies onto every target point.", "group": "where" }
]
}
diff --git a/nodes/curve.json b/nodes/curve.json
index 26973a5..12530bf 100644
--- a/nodes/curve.json
+++ b/nodes/curve.json
@@ -5,7 +5,7 @@
"outputs": 1,
"params": [
{ "name": "Points", "default": "-0.75 0.05 0; -0.25 1.05 0; 0.25 0.05 0; 0.75 1.05 0", "type": "text", "description": "Control points as x y z triples in world coordinates, separated by semicolons. The curve passes through each one; Edit Handles lets you drag them in the viewport." },
- { "name": "Segments", "default": "8", "type": "spinbox", "min": 1, "max": 64, "step": 1, "description": "Number of straight pieces sampled between each pair of control points. Higher values give a smoother curve." },
- { "name": "Thickness", "default": "0.02", "type": "slider", "description": "Width of the curve's square cross-section in world units." }
+ { "name": "Segments", "default": "8", "type": "spinbox", "min": 1, "max": 64, "step": 1, "description": "Number of straight pieces sampled between each pair of control points. Higher values give a smoother curve.", "group": "shape" },
+ { "name": "Thickness", "default": "0.02", "type": "slider", "description": "Width of the curve's square cross-section in world units.", "group": "shape" }
]
}
diff --git a/nodes/deform.json b/nodes/deform.json
index 8415edb..7d39674 100644
--- a/nodes/deform.json
+++ b/nodes/deform.json
@@ -8,6 +8,6 @@
{ "name": "Mode", "type": "choice:Twist,Bend,Taper", "default": "Twist", "description": "Twist rotates slices about the axis, Bend curves the shape along the axis, and Taper scales slices so one end narrows and the other widens." },
{ "name": "Axis", "type": "choice:X,Y,Z", "default": "Y", "description": "The axis the deformation runs along. The middle of the geometry's extent on this axis stays still and the two ends deform in opposite ways." },
{ "name": "Amount", "type": "slider", "default": "1.00", "min": -4.0, "max": 4.0, "step": 0.05, "description": "Strength of the deformation over the geometry's full length: radians of rotation for Twist and Bend, change in scale for Taper. Negative reverses it." },
- { "name": "Group", "type": "group", "default": "", "description": "Point group to deform; other points stay where they are. Empty deforms every point." }
+ { "name": "Group", "type": "group", "default": "", "description": "Point group to deform; other points stay where they are. Empty deforms every point.", "group": "where" }
]
}
diff --git a/nodes/detangle.json b/nodes/detangle.json
index 12386de..618d050 100644
--- a/nodes/detangle.json
+++ b/nodes/detangle.json
@@ -9,10 +9,10 @@
{ "name": "Thickness", "type": "slider", "default": "1.00", "min": 0.0, "max": 8.0, "step": 0.05, "description": "How close the surface may come to itself, in mean edge lengths, so the setting holds after a remesh. Larger keeps sheets further apart; 0 turns the node off." },
{ "name": "Rings", "type": "spinbox", "default": "2", "min": 0.0, "max": 6.0, "step": 1.0, "description": "How many edge rings around each point are excluded from contact, since near neighbours are close by construction. More rings ignore more of the local surface." },
{ "name": "Iterations", "type": "spinbox", "default": "4", "min": 1.0, "max": 32.0, "step": 1.0, "description": "Most separation passes per evaluation. More resolves deeper contacts; the solve stops early once a pass finds nothing to separate." },
- { "name": "Edge Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Also tests edges against nearby edges, catching two edges that cross with no point near a triangle. Much more thorough on dense contact, and slower." },
- { "name": "Fold Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Inside a simnet, also catches the surface folding through its own excluded neighbourhood since the step began, and puts it back. Has no effect outside a simnet." },
- { "name": "Step Limit", "type": "slider", "default": "0.00", "min": 0.0, "max": 2.0, "step": 0.05, "show_when": "Method == Surface", "description": "Inside a simnet, caps how far each point may move in one step, in thicknesses, before contacts are resolved. 0 means no limit; meant for use with substeps." },
- { "name": "Group", "type": "group", "default": "", "description": "Only points in this group are moved; the rest still push but stay put. Empty moves every point." },
- { "name": "Tangled Group", "type": "group", "default": "", "description": "If set, a point group of this name is written with the points of edges and triangles still crossing each other after the solve. Empty skips this extra search." }
+ { "name": "Edge Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Also tests edges against nearby edges, catching two edges that cross with no point near a triangle. Much more thorough on dense contact, and slower.", "group": "surface" },
+ { "name": "Fold Contact", "type": "toggle", "default": "true", "show_when": "Method == Surface", "description": "Inside a simnet, also catches the surface folding through its own excluded neighbourhood since the step began, and puts it back. Has no effect outside a simnet.", "group": "surface" },
+ { "name": "Step Limit", "type": "slider", "default": "0.00", "min": 0.0, "max": 2.0, "step": 0.05, "show_when": "Method == Surface", "description": "Inside a simnet, caps how far each point may move in one step, in thicknesses, before contacts are resolved. 0 means no limit; meant for use with substeps.", "group": "surface" },
+ { "name": "Group", "type": "group", "default": "", "description": "Only points in this group are moved; the rest still push but stay put. Empty moves every point.", "group": "where" },
+ { "name": "Tangled Group", "type": "group", "default": "", "description": "If set, a point group of this name is written with the points of edges and triangles still crossing each other after the solve. Empty skips this extra search.", "group": "output" }
]
}
diff --git a/nodes/develop.json b/nodes/develop.json
index 4b09c13..5fc4203 100644
--- a/nodes/develop.json
+++ b/nodes/develop.json
@@ -9,6 +9,6 @@
{ "name": "Scale", "type": "slider", "default": "0.10", "min": -2.0, "max": 2.0, "step": 0.01, "description": "Multiplier on the attribute's value, giving the displacement in world units per unit of attribute. Negative moves points the opposite way." },
{ "name": "Direction", "type": "choice:Normal,Attribute", "default": "Normal", "description": "Normal moves each point along its surface normal. Attribute moves it along a vector attribute named by Source instead." },
{ "name": "Source", "type": "attribute", "default": "", "description": "Name of the vector point attribute used as the direction when Direction is Attribute. Its length also scales the move." },
- { "name": "Group", "type": "group", "default": "", "description": "Point group to displace; other points stay put. Empty displaces every point." }
+ { "name": "Group", "type": "group", "default": "", "description": "Point group to displace; other points stay put. Empty displaces every point.", "group": "where" }
]
}
diff --git a/nodes/embryo.json b/nodes/embryo.json
index 7578d66..01ad6a9 100644
--- a/nodes/embryo.json
+++ b/nodes/embryo.json
@@ -30,7 +30,8 @@
"max": 100,
"step": 1,
"show_when": "Source == Internal",
- "description": "Rows and columns of the internal UV sphere. Higher values give a smoother, denser seed."
+ "description": "Rows and columns of the internal UV sphere. Higher values give a smoother, denser seed.",
+ "group": "source"
},
{
"name": "Radius",
@@ -40,7 +41,8 @@
"max": 10.0,
"step": 0.01,
"show_when": "Source == Internal",
- "description": "Radius of the internal sphere in world units, centered at the origin."
+ "description": "Radius of the internal sphere in world units, centered at the origin.",
+ "group": "source"
},
{
"name": "Scatter Count",
@@ -50,7 +52,8 @@
"max": 10000,
"step": 10,
"show_when": "Method == Scatter",
- "description": "Number of points scattered over the source surface before the hull is built. More points give a finer hull mesh."
+ "description": "Number of points scattered over the source surface before the hull is built. More points give a finer hull mesh.",
+ "group": "scatter"
},
{
"name": "Scatter Seed",
@@ -60,14 +63,16 @@
"max": 10.0,
"step": 0.1,
"show_when": "Method == Scatter",
- "description": "Random seed for the scatter. Change it for a different but equally spaced point layout."
+ "description": "Random seed for the scatter. Change it for a different but equally spaced point layout.",
+ "group": "scatter"
},
{
"name": "Relax Points",
"type": "toggle",
"default": "true",
"show_when": "Method == Scatter",
- "description": "When on, pushes the scattered points apart across the surface so they are evenly spaced before the hull is built."
+ "description": "When on, pushes the scattered points apart across the surface so they are evenly spaced before the hull is built.",
+ "group": "scatter"
},
{
"name": "Scatter Relax Iterations",
@@ -77,7 +82,8 @@
"max": 100,
"step": 1,
"show_when": "Method == Scatter && Relax Points == true",
- "description": "How many push-apart passes the scattered points get. More passes give a more even spacing; 0 does none."
+ "description": "How many push-apart passes the scattered points get. More passes give a more even spacing; 0 does none.",
+ "group": "scatter"
},
{
"name": "Scale Radii By",
@@ -87,14 +93,16 @@
"max": 2.0,
"step": 0.001,
"show_when": "Method == Scatter && Relax Points == true",
- "description": "Multiplier on each scattered point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully."
+ "description": "Multiplier on each scattered point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully.",
+ "group": "scatter"
},
{
"name": "Use Max Relax Radius",
"type": "toggle",
"default": "true",
"show_when": "Method == Scatter && Relax Points == true",
- "description": "When on, the scattered points' push radius is capped at Scatter Relax Radius."
+ "description": "When on, the scattered points' push radius is capped at Scatter Relax Radius.",
+ "group": "scatter"
},
{
"name": "Scatter Relax Radius",
@@ -104,7 +112,8 @@
"max": 100.0,
"step": 0.1,
"show_when": "Method == Scatter && Relax Points == true && Use Max Relax Radius == true",
- "description": "Upper limit on the scattered points' push radius in world units, used when Use Max Relax Radius is on."
+ "description": "Upper limit on the scattered points' push radius in world units, used when Use Max Relax Radius is on.",
+ "group": "scatter"
},
{
"name": "Relax Iterations",
@@ -113,7 +122,8 @@
"min": 0,
"max": 50,
"step": 1,
- "description": "Passes of the final repel relax that pushes the mesh's points apart. 0 turns the relax off."
+ "description": "Passes of the final repel relax that pushes the mesh's points apart. 0 turns the relax off.",
+ "group": "relax"
},
{
"name": "Relax Radius",
@@ -122,13 +132,15 @@
"min": 0.001,
"max": 1.0,
"step": 0.001,
- "description": "Radius in world units of the sphere each point pushes with in the final relax; points closer than twice this move apart."
+ "description": "Radius in world units of the sphere each point pushes with in the final relax; points closer than twice this move apart.",
+ "group": "relax"
},
{
"name": "Relax in 3D Space",
"type": "toggle",
"default": "false",
- "description": "When off, the final relax slides points along the surface to keep its shape; when on, points may move freely in 3D."
+ "description": "When off, the final relax slides points along the surface to keep its shape; when on, points may move freely in 3D.",
+ "group": "relax"
},
{
"name": "Subdivision Depth",
@@ -137,7 +149,8 @@
"min": 0,
"max": 3,
"step": 1,
- "description": "How many times each triangle is split into four at the end, without smoothing. 0 leaves the mesh as is."
+ "description": "How many times each triangle is split into four at the end, without smoothing. 0 leaves the mesh as is.",
+ "group": "subdivide"
}
],
"children": [
diff --git a/nodes/export.json b/nodes/export.json
index 20e7573..f2701d7 100644
--- a/nodes/export.json
+++ b/nodes/export.json
@@ -8,6 +8,6 @@
{ "name": "File", "type": "text", "default": "", "description": "Path of the file to write; ~/ means your home folder and missing folders are created. An existing file is overwritten. Required before exporting." },
{ "name": "Format", "type": "choice:STL,STL (ASCII),OBJ", "default": "STL", "description": "Mesh format for geometry: binary STL, ASCII STL (readable text, larger), or OBJ, which keeps shared points and quads. Ignored for pages, always PNG." },
{ "name": "Scale", "type": "slider", "default": "1.00", "min": 0.001, "max": 100.0, "step": 0.01, "description": "Multiplies every coordinate as the mesh is written, for example to convert units for a slicer. Does not affect the scene, or a page's PNG." },
- { "name": "Export", "type": "button", "default": "", "description": "Writes the input to File now, at the current frame, and reports the result on the status line. Nothing is written when the node is merely evaluated." }
+ { "name": "Export", "type": "button", "default": "", "description": "Writes the input to File now, at the current frame, and reports the result on the status line. Nothing is written when the node is merely evaluated.", "group": "action" }
]
}
diff --git a/nodes/grid.json b/nodes/grid.json
index 55b2ae0..f9086b0 100644
--- a/nodes/grid.json
+++ b/nodes/grid.json
@@ -6,8 +6,8 @@
"params": [
{ "name": "Rows", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0, "description": "Number of quad divisions along the length (Z). More rows give a finer grid." },
{ "name": "Columns", "type": "spinbox", "default": "10", "min": 1.0, "max": 500.0, "step": 1.0, "description": "Number of quad divisions across the width (X). More columns give a finer grid." },
- { "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along X in world units." },
- { "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along Z in world units." },
- { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the grid's center in world units; the grid lies flat in the XZ plane through this point." }
+ { "name": "Width", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along X in world units.", "group": "size" },
+ { "name": "Length", "type": "slider", "default": "1.00", "min": 0.01, "max": 20.0, "step": 0.05, "description": "Size of the grid along Z in world units.", "group": "size" },
+ { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the grid's center in world units; the grid lies flat in the XZ plane through this point.", "group": "size" }
]
}
diff --git a/nodes/group.json b/nodes/group.json
index c4307b0..ae873c3 100644
--- a/nodes/group.json
+++ b/nodes/group.json
@@ -37,7 +37,8 @@
"max": 500.0,
"step": 1.0,
"show_when": "Mode == Random",
- "description": "How many elements are picked at random. More than there are selects them all."
+ "description": "How many elements are picked at random. More than there are selects them all.",
+ "group": "mode"
},
{
"name": "Seed",
@@ -47,7 +48,8 @@
"max": 999.0,
"step": 1.0,
"show_when": "Mode == Random",
- "description": "Changes which elements are picked at random; the same seed always picks the same ones."
+ "description": "Changes which elements are picked at random; the same seed always picks the same ones.",
+ "group": "mode"
},
{
"name": "Center",
@@ -56,7 +58,8 @@
"min": -10.0,
"max": 10.0,
"show_when": "Mode == Box",
- "description": "The centre of the selection box, in world units."
+ "description": "The centre of the selection box, in world units.",
+ "group": "mode"
},
{
"name": "Size",
@@ -65,47 +68,54 @@
"min": 0.0,
"max": 20.0,
"show_when": "Mode == Box",
- "description": "The full width, height and depth of the selection box, in world units. Elements inside or on its faces are selected."
+ "description": "The full width, height and depth of the selection box, in world units. Elements inside or on its faces are selected.",
+ "group": "mode"
},
{
"name": "Invert",
"type": "toggle",
"default": "false",
- "description": "Selects everything the mode would not select, and nothing it would."
+ "description": "Selects everything the mode would not select, and nothing it would.",
+ "group": "output"
},
{
"name": "Highlight",
"type": "toggle",
"default": "true",
- "description": "Tints the selected points amber so the group can be seen in the viewport. Changes their colour in the output."
+ "description": "Tints the selected points amber so the group can be seen in the viewport. Changes their colour in the output.",
+ "group": "output"
},
{
"name": "Attribute",
"type": "attribute",
"default": "",
"show_when": "Mode == Attribute",
- "description": "The point attribute compared against Threshold; points without it are not selected."
+ "description": "The point attribute compared against Threshold; points without it are not selected.",
+ "group": "mode"
},
{
"name": "Comparison",
"type": "choice:Below,Above",
"default": "Above",
"show_when": "Mode == Attribute",
- "description": "Whether points whose attribute is below or above Threshold are selected. A value equal to Threshold is never selected."
+ "description": "Whether points whose attribute is below or above Threshold are selected. A value equal to Threshold is never selected.",
+ "group": "mode"
},
{
"name": "Threshold",
"type": "float",
"default": "0.50",
"show_when": "Mode == Attribute",
- "description": "The value the attribute is compared against."
+ "description": "The value the attribute is compared against.",
+ "group": "mode"
},
{
"name": "Source Group",
"type": "group",
"default": "",
"show_when": "Mode == Expand",
- "description": "The existing point group that is grown or shrunk across the mesh."
+ "description": "The existing point group that is grown or shrunk across the mesh.",
+ "group": "mode"
},
{
"name": "Rings",
@@ -115,7 +125,8 @@
"max": 8.0,
"step": 1.0,
"show_when": "Mode == Expand",
- "description": "How many rings of neighbouring points the source group grows by. Negative shrinks it by peeling its boundary; 0 copies it unchanged."
+ "description": "How many rings of neighbouring points the source group grows by. Negative shrinks it by peeling its boundary; 0 copies it unchanged.",
+ "group": "mode"
}
]
}
diff --git a/nodes/mold_shell.json b/nodes/mold_shell.json
index 833267f..82361b5 100644
--- a/nodes/mold_shell.json
+++ b/nodes/mold_shell.json
@@ -7,7 +7,7 @@
{ "name": "Input", "type": "node", "default": "", "description": "The node whose surface is the cast; a shell is built inward from it with a thickness that follows its curvature." },
{ "name": "Maximum Thickness", "type": "slider", "default": "0.75", "min": 0.001, "max": 5.0, "step": 0.01, "description": "Shell thickness in world units where the surface is most concave, cupping inward, where a mold needs the most material." },
{ "name": "Minimum Thickness", "type": "slider", "default": "0.60", "min": 0.001, "max": 5.0, "step": 0.01, "description": "Shell thickness in world units where the surface is most convex, bulging outward." },
- { "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01, "description": "Edge length in world units the surface is remeshed to before thickening, which sets how finely the thickness can vary." },
+ { "name": "Remesh Division Size", "type": "slider", "default": "0.90", "min": 0.01, "max": 5.0, "step": 0.01, "description": "Edge length in world units the surface is remeshed to before thickening, which sets how finely the thickness can vary.", "group": "remesh" },
{ "name": "Ramp", "type": "choice:Linear,Smooth,Constant", "default": "Linear", "description": "How curvature maps to thickness between the minimum and maximum. Linear is proportional, Smooth eases at both ends, Constant uses the maximum everywhere." }
]
}
diff --git a/nodes/neighbour.json b/nodes/neighbour.json
index 4fd3cb8..c88ad04 100644
--- a/nodes/neighbour.json
+++ b/nodes/neighbour.json
@@ -27,7 +27,8 @@
"type": "choice:Connectivity,Radius,Global",
"default": "Connectivity",
"show_when": "Mode != Bleed",
- "description": "Which points count as neighbours: those within Rings edges on the mesh, those within Radius in space (across folds too), or every point."
+ "description": "Which points count as neighbours: those within Rings edges on the mesh, those within Radius in space (across folds too), or every point.",
+ "group": "neighbourhood"
},
{
"name": "Rings",
@@ -37,7 +38,8 @@
"max": 8.0,
"step": 1.0,
"show_when": "Neighbourhood == Connectivity && Mode != Bleed",
- "description": "How many edge rings out from a point count as its neighbours. More rings spread the effect wider per evaluation."
+ "description": "How many edge rings out from a point count as its neighbours. More rings spread the effect wider per evaluation.",
+ "group": "neighbourhood"
},
{
"name": "Radius",
@@ -47,7 +49,8 @@
"max": 2.0,
"step": 0.01,
"show_when": "Neighbourhood == Radius && Mode != Bleed",
- "description": "Distance in world units within which points count as neighbours, regardless of mesh connectivity."
+ "description": "Distance in world units within which points count as neighbours, regardless of mesh connectivity.",
+ "group": "neighbourhood"
},
{
"name": "Amount",
@@ -56,41 +59,47 @@
"min": 0.0,
"max": 1.0,
"step": 0.01,
- "description": "Strength of each evaluation, 0 to 1: the fraction of the way toward the target, the fraction moved or decayed, or the charge added per step. Inside a simnet it acts as a rate."
+ "description": "Strength of each evaluation, 0 to 1: the fraction of the way toward the target, the fraction moved or decayed, or the charge added per step. Inside a simnet it acts as a rate.",
+ "group": "amount"
},
{
"name": "Direction",
"type": "attribute",
"default": "",
"show_when": "Mode == Migrate",
- "description": "A vector point attribute giving the direction value flows in Migrate mode; neighbours in front of each point receive what it gives up."
+ "description": "A vector point attribute giving the direction value flows in Migrate mode; neighbours in front of each point receive what it gives up.",
+ "group": "amount"
},
{
"name": "Group",
"type": "group",
"default": "",
- "description": "Only points in this group are changed; neighbours outside it are still read. Empty changes every point."
+ "description": "Only points in this group are changed; neighbours outside it are still read. Empty changes every point.",
+ "group": "where"
},
{
"name": "Target",
"type": "choice:Local Average,Global Average,Constant,Attribute,Surface Tangent",
"default": "Local Average",
"show_when": "Mode == Align",
- "description": "What vectors turn toward: the neighbours' average, the whole geometry's average, the Constant vector, the Source attribute, or the vector's own projection onto the surface."
+ "description": "What vectors turn toward: the neighbours' average, the whole geometry's average, the Constant vector, the Source attribute, or the vector's own projection onto the surface.",
+ "group": "mode"
},
{
"name": "Constant",
"type": "float3",
"default": "0.00:1.00:0.00",
"show_when": "Mode == Align && Target == Constant",
- "description": "The fixed direction vectors turn toward when Target is Constant."
+ "description": "The fixed direction vectors turn toward when Target is Constant.",
+ "group": "mode"
},
{
"name": "Source",
"type": "attribute",
"default": "",
"show_when": "Mode == Align|Lead",
- "description": "In Align with Target set to Attribute, the vector attribute to turn toward. In Lead, a number weighting each neighbour's influence; empty weighs all equally."
+ "description": "In Align with Target set to Attribute, the vector attribute to turn toward. In Lead, a number weighting each neighbour's influence; empty weighs all equally.",
+ "group": "mode"
},
{
"name": "Release",
@@ -100,7 +109,8 @@
"max": 10.0,
"step": 0.05,
"show_when": "Mode == Charge",
- "description": "Threshold at which a point's charge discharges, split evenly among its neighbours. Higher stores more before firing; 0 never discharges."
+ "description": "Threshold at which a point's charge discharges, split evenly among its neighbours. Higher stores more before firing; 0 never discharges.",
+ "group": "mode"
}
]
}
diff --git a/nodes/page.json b/nodes/page.json
index da8c5d2..fcb5cf2 100644
--- a/nodes/page.json
+++ b/nodes/page.json
@@ -9,8 +9,8 @@
{ "name": "Width", "type": "float", "default": "8.5", "description": "Page width in Units; with Height, the page's size. A preset writes it, and any other width can be typed; swap it with Height for landscape. In Pixels its physical size depends on Resolution." },
{ "name": "Height", "type": "float", "default": "11.0", "description": "Page height in Units; with Width, the page's size. A preset writes it, and any other height can be typed. In Pixels its physical size depends on Resolution." },
{ "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6, "description": "Pixels per inch (DPI). More pixels for a paper size; for pixel presets it sets the physical size. Stored in exported PNGs so prints come out at size." },
- { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "description": "Background colour the whole page is filled with before anything is drawn on it." },
- { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "description": "Opacity of the background fill, 0 to 1. At 0 the page is clear and only what is drawn on it shows; clear areas are see-through in the viewport and PNG." },
- { "name": "Position", "type": "float3", "default": "0.00:0.00:0.00", "min": -1000.0, "max": 1000.0, "description": "Where the centre of the page stands in the 3D scene, in world units. The page lies in the XY plane facing +Z at its physical size." }
+ { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "description": "Background colour the whole page is filled with before anything is drawn on it.", "group": "look" },
+ { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "description": "Opacity of the background fill, 0 to 1. At 0 the page is clear and only what is drawn on it shows; clear areas are see-through in the viewport and PNG.", "group": "look" },
+ { "name": "Position", "type": "float3", "default": "0.00:0.00:0.00", "min": -1000.0, "max": 1000.0, "description": "Where the centre of the page stands in the 3D scene, in world units. The page lies in the XY plane facing +Z at its physical size.", "group": "place" }
]
}
diff --git a/nodes/page_grid.json b/nodes/page_grid.json
index 6640cbd..d9918db 100644
--- a/nodes/page_grid.json
+++ b/nodes/page_grid.json
@@ -8,7 +8,7 @@
{ "name": "Cell Size", "type": "float", "default": "0.25", "description": "Spacing between grid lines, in the page's Units. Lines start at the top-left corner; smaller values give a denser grid, never finer than one pixel." },
{ "name": "Line Width", "type": "float", "default": "0.01", "description": "Thickness of each grid line, in the page's Units, centred on its coordinate. Very small values are drawn as a hairline at least half a pixel wide." },
{ "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the grid lines, drawn fully opaque over the page." },
- { "name": "Fill Cells", "type": "toggle", "default": "false", "description": "When on, fills the whole page with Cell Color before ruling the lines, covering what the input had drawn. When off, lines are drawn over the input as is." },
- { "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true", "description": "Colour the page is filled with under the grid lines when Fill Cells is on." }
+ { "name": "Fill Cells", "type": "toggle", "default": "false", "description": "When on, fills the whole page with Cell Color before ruling the lines, covering what the input had drawn. When off, lines are drawn over the input as is.", "group": "fill" },
+ { "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true", "description": "Colour the page is filled with under the grid lines when Fill Cells is on.", "group": "fill" }
]
}
diff --git a/nodes/page_shape.json b/nodes/page_shape.json
index 23cb954..6d5350a 100644
--- a/nodes/page_shape.json
+++ b/nodes/page_shape.json
@@ -6,19 +6,19 @@
"params": [
{ "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image the shape is drawn onto. Without one the node produces nothing." },
{ "name": "Shape", "type": "choice:Rectangle,Ellipse,Line,Polygon", "default": "Rectangle", "description": "Outline to draw: a rectangle, an ellipse, a straight line, or a regular polygon fitted inside the Width by Height box." },
- { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the shape's centre, in the page's Units from the page's left edge." },
- { "name": "Y", "type": "float", "default": "5.50", "description": "Vertical position of the shape's centre, in the page's Units from the page's top edge (y runs down)." },
- { "name": "Width", "type": "float", "default": "3.00", "description": "Width of the shape's box before rotation, in the page's Units. For a line, its length." },
- { "name": "Height", "type": "float", "default": "2.00", "show_when": "Shape != Line", "description": "Height of the shape's box before rotation, in the page's Units. A line's thickness comes from Stroke Width instead." },
- { "name": "Rotation", "type": "slider", "default": "0", "min": -180.0, "max": 180.0, "step": 1.0, "description": "Turns the shape about its centre, in degrees; positive is clockwise as the page is viewed." },
- { "name": "Corner Radius", "type": "float", "default": "0.00", "show_when": "Shape == Rectangle", "description": "Rounds the rectangle's corners, in the page's Units. 0 gives sharp corners; limited to half the shorter side." },
- { "name": "Sides", "type": "spinbox", "default": "3", "min": 3, "max": 64, "step": 1, "show_when": "Shape == Polygon", "description": "Number of corners of the polygon, 3 to 64, with the first corner at the top. More sides approach an ellipse." },
- { "name": "Fill", "type": "toggle", "default": "true", "show_when": "Shape != Line", "description": "When on, the inside of the shape is filled with Fill Color. Turn off for an outline only." },
- { "name": "Fill Color", "type": "float3", "default": "0.20:0.45:0.85", "min": 0.0, "max": 1.0, "show_when": "Shape != Line && Fill == true", "description": "Colour of the shape's fill; how solid it is comes from Fill Opacity." },
- { "name": "Fill Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Shape != Line && Fill == true", "description": "Opacity of the fill, 0 (invisible) to 1 (solid), blended over what is already on the page." },
- { "name": "Stroke", "type": "toggle", "default": "true", "description": "When on, the outline is drawn in Stroke Color at Stroke Width. A line shape is drawn only while this is on." },
- { "name": "Stroke Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "show_when": "Stroke == true", "description": "Colour of the outline, and of the whole line for the Line shape." },
- { "name": "Stroke Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Stroke == true", "description": "Opacity of the outline, 0 (invisible) to 1 (solid), blended over the fill and the page." },
- { "name": "Stroke Width", "type": "float", "default": "0.02", "show_when": "Stroke == true", "description": "Thickness of the outline in the page's Units, centred on the shape's edge (half inside, half out). For a line, its thickness." }
+ { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the shape's centre, in the page's Units from the page's left edge.", "group": "geometry" },
+ { "name": "Y", "type": "float", "default": "5.50", "description": "Vertical position of the shape's centre, in the page's Units from the page's top edge (y runs down).", "group": "geometry" },
+ { "name": "Width", "type": "float", "default": "3.00", "description": "Width of the shape's box before rotation, in the page's Units. For a line, its length.", "group": "geometry" },
+ { "name": "Height", "type": "float", "default": "2.00", "show_when": "Shape != Line", "description": "Height of the shape's box before rotation, in the page's Units. A line's thickness comes from Stroke Width instead.", "group": "geometry" },
+ { "name": "Rotation", "type": "slider", "default": "0", "min": -180.0, "max": 180.0, "step": 1.0, "description": "Turns the shape about its centre, in degrees; positive is clockwise as the page is viewed.", "group": "geometry" },
+ { "name": "Corner Radius", "type": "float", "default": "0.00", "show_when": "Shape == Rectangle", "description": "Rounds the rectangle's corners, in the page's Units. 0 gives sharp corners; limited to half the shorter side.", "group": "geometry" },
+ { "name": "Sides", "type": "spinbox", "default": "3", "min": 3, "max": 64, "step": 1, "show_when": "Shape == Polygon", "description": "Number of corners of the polygon, 3 to 64, with the first corner at the top. More sides approach an ellipse.", "group": "geometry" },
+ { "name": "Fill", "type": "toggle", "default": "true", "show_when": "Shape != Line", "description": "When on, the inside of the shape is filled with Fill Color. Turn off for an outline only.", "group": "fill" },
+ { "name": "Fill Color", "type": "float3", "default": "0.20:0.45:0.85", "min": 0.0, "max": 1.0, "show_when": "Shape != Line && Fill == true", "description": "Colour of the shape's fill; how solid it is comes from Fill Opacity.", "group": "fill" },
+ { "name": "Fill Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Shape != Line && Fill == true", "description": "Opacity of the fill, 0 (invisible) to 1 (solid), blended over what is already on the page.", "group": "fill" },
+ { "name": "Stroke", "type": "toggle", "default": "true", "description": "When on, the outline is drawn in Stroke Color at Stroke Width. A line shape is drawn only while this is on.", "group": "stroke" },
+ { "name": "Stroke Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "show_when": "Stroke == true", "description": "Colour of the outline, and of the whole line for the Line shape.", "group": "stroke" },
+ { "name": "Stroke Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "show_when": "Stroke == true", "description": "Opacity of the outline, 0 (invisible) to 1 (solid), blended over the fill and the page.", "group": "stroke" },
+ { "name": "Stroke Width", "type": "float", "default": "0.02", "show_when": "Stroke == true", "description": "Thickness of the outline in the page's Units, centred on the shape's edge (half inside, half out). For a line, its thickness.", "group": "stroke" }
]
}
diff --git a/nodes/page_text.json b/nodes/page_text.json
index 11df1e9..3a73679 100644
--- a/nodes/page_text.json
+++ b/nodes/page_text.json
@@ -6,13 +6,13 @@
"params": [
{ "name": "Input", "type": "node", "default": "", "description": "The page node (or page chain) whose image the text is drawn onto. Without one the node produces nothing." },
{ "name": "Text", "type": "text", "default": "Title", "description": "The text to draw. It is not wrapped: a long line runs past the page edge rather than breaking." },
- { "name": "Font", "type": "text", "default": "", "description": "Font family name, such as a system font installed on this machine. Empty uses the default font." },
- { "name": "Size", "type": "float", "default": "0.25", "description": "Font size in the page's Units. The same size prints the same at any Resolution; only the pixel count changes." },
- { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the text, drawn fully opaque over the page." },
- { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the text's anchor, in the page's Units from the left edge. Horizontal decides which part of the text sits here." },
- { "name": "Y", "type": "float", "default": "0.80", "description": "Vertical position of the text's anchor, in the page's Units from the top edge (y runs down). Vertical decides which part of the text sits here." },
- { "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center", "description": "Which part of the text is placed at X: its left edge, its centre, or its right edge." },
- { "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top", "description": "Which part of the text block is placed at Y: its top, its middle, or its bottom." },
- { "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05, "description": "Line spacing as a multiple of Size. 1 sets lines tightly; larger values space multi-line text further apart." }
+ { "name": "Font", "type": "text", "default": "", "description": "Font family name, such as a system font installed on this machine. Empty uses the default font.", "group": "type" },
+ { "name": "Size", "type": "float", "default": "0.25", "description": "Font size in the page's Units. The same size prints the same at any Resolution; only the pixel count changes.", "group": "type" },
+ { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0, "description": "Colour of the text, drawn fully opaque over the page.", "group": "type" },
+ { "name": "X", "type": "float", "default": "4.25", "description": "Horizontal position of the text's anchor, in the page's Units from the left edge. Horizontal decides which part of the text sits here.", "group": "layout" },
+ { "name": "Y", "type": "float", "default": "0.80", "description": "Vertical position of the text's anchor, in the page's Units from the top edge (y runs down). Vertical decides which part of the text sits here.", "group": "layout" },
+ { "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center", "description": "Which part of the text is placed at X: its left edge, its centre, or its right edge.", "group": "layout" },
+ { "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top", "description": "Which part of the text block is placed at Y: its top, its middle, or its bottom.", "group": "layout" },
+ { "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05, "description": "Line spacing as a multiple of Size. 1 sets lines tightly; larger values space multi-line text further apart.", "group": "layout" }
]
}
diff --git a/nodes/plane.json b/nodes/plane.json
index 676c8b3..4c15d2f 100644
--- a/nodes/plane.json
+++ b/nodes/plane.json
@@ -38,25 +38,29 @@
"name": "Center X",
"default": "0.0",
"type": "slider:-2:2",
- "description": "X position of the sheet's center in world units."
+ "description": "X position of the sheet's center in world units.",
+ "group": "place"
},
{
"name": "Center Y",
"default": "0.0",
"type": "slider:-2:2",
- "description": "Height of the flat sheet in world units; the plane lies level in XZ at this Y."
+ "description": "Height of the flat sheet in world units; the plane lies level in XZ at this Y.",
+ "group": "place"
},
{
"name": "Center Z",
"default": "0.0",
"type": "slider:-2:2",
- "description": "Z position of the sheet's center in world units."
+ "description": "Z position of the sheet's center in world units.",
+ "group": "place"
},
{
"name": "Color",
"default": "true",
"type": "toggle",
- "description": "When on, colors the points with a gradient across the sheet; when off, the plane uses the plain default color."
+ "description": "When on, colors the points with a gradient across the sheet; when off, the plane uses the plain default color.",
+ "group": "look"
}
]
}
diff --git a/nodes/points.json b/nodes/points.json
index 6ae9e7c..4265826 100644
--- a/nodes/points.json
+++ b/nodes/points.json
@@ -17,13 +17,15 @@
"name": "Shape",
"type": "choice:None,Spiral,Line,Circle,Grid",
"default": "None",
- "description": "Pattern the points are laid out in: Spiral, Line, Circle or a square Grid. None puts every point at the same spot. Placed automatically by the node's order in the network."
+ "description": "Pattern the points are laid out in: Spiral, Line, Circle or a square Grid. None puts every point at the same spot. Placed automatically by the node's order in the network.",
+ "group": "display"
},
{
"name": "Markers",
"type": "choice:true,false",
"default": "true",
- "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read."
+ "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read.",
+ "group": "display"
}
]
}
diff --git a/nodes/polygon.json b/nodes/polygon.json
index 2949b83..20ef604 100644
--- a/nodes/polygon.json
+++ b/nodes/polygon.json
@@ -8,6 +8,6 @@
{ "name": "Radius", "type": "slider", "default": "0.50", "min": 0.01, "max": 10.0, "step": 0.01, "description": "Distance from the center to each outer corner, in world units." },
{ "name": "Inner Radius", "type": "slider", "default": "0.00", "min": 0.0, "max": 10.0, "step": 0.01, "description": "Distance of the inner corners for a star, in world units. 0 makes a regular polygon; above 0 every other corner is pulled to this radius." },
{ "name": "Fill", "type": "choice:true,false", "default": "true", "description": "true builds a filled shape of triangles fanned from the center; false builds only the outline as edges." },
- { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the shape's center in world units; the polygon lies flat in the XZ plane through this point." }
+ { "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Position of the shape's center in world units; the polygon lies flat in the XZ plane through this point.", "group": "place" }
]
}
diff --git a/nodes/relax.json b/nodes/relax.json
index def34bb..3ab9f20 100644
--- a/nodes/relax.json
+++ b/nodes/relax.json
@@ -21,14 +21,16 @@
"default": "",
"type": "node",
"show_when": "Mode == Springs",
- "description": "The geometry whose edge lengths the springs restore; inside a simnet usually its input node. Must have the same point count as Input, or nothing moves."
+ "description": "The geometry whose edge lengths the springs restore; inside a simnet usually its input node. Must have the same point count as Input, or nothing moves.",
+ "group": "springs"
},
{
"name": "Pin Group",
"default": "",
"type": "group",
"show_when": "Mode == Springs",
- "description": "Points in this group keep their input position and push the others instead, so a displacement applied to them spreads through the surface. Empty pins nothing."
+ "description": "Points in this group keep their input position and push the others instead, so a displacement applied to them spreads through the surface. Empty pins nothing.",
+ "group": "springs"
},
{
"name": "Stiffness",
@@ -38,7 +40,8 @@
"max": 1.0,
"step": 0.05,
"show_when": "Mode == Springs",
- "description": "Fraction of each edge's length error corrected per pass. Higher values relax faster and stiffer; 0 leaves the points where they are."
+ "description": "Fraction of each edge's length error corrected per pass. Higher values relax faster and stiffer; 0 leaves the points where they are.",
+ "group": "springs"
},
{
"name": "Iterations",
@@ -47,7 +50,8 @@
"min": 0.0,
"max": 50.0,
"step": 1.0,
- "description": "Number of relaxation passes. More passes settle further. In Repel and Tangential modes 0 turns the node off; Springs always runs at least one."
+ "description": "Number of relaxation passes. More passes settle further. In Repel and Tangential modes 0 turns the node off; Springs always runs at least one.",
+ "group": "solve"
},
{
"name": "Radius",
@@ -57,14 +61,16 @@
"max": 1.0,
"step": 0.001,
"show_when": "Mode == Repel",
- "description": "Radius of the sphere around each point, in world units; points are pushed apart until these spheres stop overlapping. Larger spaces points further apart."
+ "description": "Radius of the sphere around each point, in world units; points are pushed apart until these spheres stop overlapping. Larger spaces points further apart.",
+ "group": "repel"
},
{
"name": "In 3D Space",
"type": "toggle",
"default": "false",
"show_when": "Mode == Repel",
- "description": "When off, points slide only along the surface (in their tangent plane) as they repel. When on, they may move freely in 3D and leave the surface."
+ "description": "When off, points slide only along the surface (in their tangent plane) as they repel. When on, they may move freely in 3D and leave the surface.",
+ "group": "repel"
},
{
"name": "Amount",
@@ -74,7 +80,8 @@
"max": 1.0,
"step": 0.01,
"show_when": "Mode == Tangential",
- "description": "How far each point moves toward the centroid of its neighbours per pass, 0 to 1, less the part along its normal, so triangles even out while the shape stays. 0 turns it off."
+ "description": "How far each point moves toward the centroid of its neighbours per pass, 0 to 1, less the part along its normal, so triangles even out while the shape stays. 0 turns it off.",
+ "group": "tangential"
}
]
}
diff --git a/nodes/remesh.json b/nodes/remesh.json
index 38f277c..1fe1eb4 100644
--- a/nodes/remesh.json
+++ b/nodes/remesh.json
@@ -41,35 +41,40 @@
"name": "Transfer",
"type": "toggle",
"default": "false",
- "description": "After remeshing, copy point attributes (and optionally groups) onto the new points from the nearest point of a source mesh, so they survive the rebuild."
+ "description": "After remeshing, copy point attributes (and optionally groups) onto the new points from the nearest point of a source mesh, so they survive the rebuild.",
+ "group": "transfer"
},
{
"name": "From",
"type": "node",
"default": "",
"show_when": "Transfer == true",
- "description": "The node whose point attributes and groups are copied onto the remeshed points. Empty uses the remesh's own input, as it was before remeshing."
+ "description": "The node whose point attributes and groups are copied onto the remeshed points. Empty uses the remesh's own input, as it was before remeshing.",
+ "group": "transfer"
},
{
"name": "Attributes",
"type": "text",
"default": "",
"show_when": "Transfer == true",
- "description": "Comma-separated names of the point attributes to copy from the source. Empty copies every point attribute the source has."
+ "description": "Comma-separated names of the point attributes to copy from the source. Empty copies every point attribute the source has.",
+ "group": "transfer"
},
{
"name": "Transfer Groups",
"type": "toggle",
"default": "false",
"show_when": "Transfer == true",
- "description": "Also copy point group membership from the nearest source point, joining and leaving groups to match the source."
+ "description": "Also copy point group membership from the nearest source point, joining and leaving groups to match the source.",
+ "group": "transfer"
},
{
"name": "Groups",
"type": "text",
"default": "",
"show_when": "Transfer == true && Transfer Groups == true",
- "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has."
+ "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has.",
+ "group": "transfer"
},
{
"name": "Maximum Distance",
@@ -79,7 +84,8 @@
"max": 10.0,
"step": 0.01,
"show_when": "Transfer == true",
- "description": "Only points within this many world units of a source point receive values; farther points keep their own. 0 means no limit."
+ "description": "Only points within this many world units of a source point receive values; farther points keep their own. 0 means no limit.",
+ "group": "transfer"
}
],
"children": [
diff --git a/nodes/scatter.json b/nodes/scatter.json
index cf7a6ad..6572521 100644
--- a/nodes/scatter.json
+++ b/nodes/scatter.json
@@ -8,12 +8,12 @@
{ "name": "Mode", "type": "choice:Volume,Surface", "default": "Volume", "description": "Volume places points inside the closed input shape; Surface places them on its surface, spread by area, with optional relaxing." },
{ "name": "Points", "type": "spinbox", "default": "100", "min": 10.0, "max": 10000.0, "step": 10.0, "description": "Number of points to scatter. In Volume mode fewer may be produced if the shape is thin or not closed." },
{ "name": "Seed", "type": "slider", "default": "1.1", "min": 0.0, "max": 10.0, "step": 0.1, "show_when": "Mode == Surface", "description": "Random seed for Surface mode. Change it for a different point layout; the same seed always gives the same points." },
- { "name": "Relax Points", "type": "toggle", "default": "false", "show_when": "Mode == Surface", "description": "When on, pushes the scattered points apart across the surface so they end up evenly spaced." },
- { "name": "Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Mode == Surface && Relax Points == true", "description": "How many push-apart passes the points get. More passes give a more even spacing; 0 does none." },
- { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Mode == Surface && Relax Points == true", "description": "Multiplier on each point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully." },
- { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Mode == Surface && Relax Points == true", "description": "When on, the push radius is capped at Max Relax Radius." },
- { "name": "Max Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Mode == Surface && Relax Points == true && Use Max Relax Radius == true", "description": "Upper limit on each point's push radius in world units, used when Use Max Relax Radius is on." },
- { "name": "Radius", "type": "slider", "default": "0.02", "min": 0.005, "max": 0.1, "step": 0.005, "show_when": "Markers == true", "description": "Size of the marker sphere drawn at each point, in world units." },
- { "name": "Markers", "type": "choice:true,false", "default": "true", "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read." }
+ { "name": "Relax Points", "type": "toggle", "default": "false", "show_when": "Mode == Surface", "description": "When on, pushes the scattered points apart across the surface so they end up evenly spaced.", "group": "relax" },
+ { "name": "Relax Iterations", "type": "spinbox", "default": "50", "min": 0, "max": 100, "step": 1, "show_when": "Mode == Surface && Relax Points == true", "description": "How many push-apart passes the points get. More passes give a more even spacing; 0 does none.", "group": "relax" },
+ { "name": "Scale Radii By", "type": "slider", "default": "1.248", "min": 0.0, "max": 2.0, "step": 0.001, "show_when": "Mode == Surface && Relax Points == true", "description": "Multiplier on each point's push radius, which is derived from the surface area per point. Larger values spread points more forcefully.", "group": "relax" },
+ { "name": "Use Max Relax Radius", "type": "toggle", "default": "true", "show_when": "Mode == Surface && Relax Points == true", "description": "When on, the push radius is capped at Max Relax Radius.", "group": "relax" },
+ { "name": "Max Relax Radius", "type": "slider", "default": "10", "min": 0.0, "max": 100.0, "step": 0.1, "show_when": "Mode == Surface && Relax Points == true && Use Max Relax Radius == true", "description": "Upper limit on each point's push radius in world units, used when Use Max Relax Radius is on.", "group": "relax" },
+ { "name": "Radius", "type": "slider", "default": "0.02", "min": 0.005, "max": 0.1, "step": 0.005, "show_when": "Markers == true", "description": "Size of the marker sphere drawn at each point, in world units.", "group": "display" },
+ { "name": "Markers", "type": "choice:true,false", "default": "true", "description": "true draws a small sphere at each point so they can be seen; false outputs bare points only, which is what Copy and other nodes should read.", "group": "display" }
]
}
diff --git a/nodes/simnet.json b/nodes/simnet.json
index 81582b1..7a8644a 100644
--- a/nodes/simnet.json
+++ b/nodes/simnet.json
@@ -29,7 +29,8 @@
"name": "Cache",
"type": "choice:false,true",
"default": "false",
- "description": "When true, the latest solved frame is saved to disk so a later session can resume from it instead of solving from the seed."
+ "description": "When true, the latest solved frame is saved to disk so a later session can resume from it instead of solving from the seed.",
+ "group": "cache"
}
],
"children": [
diff --git a/nodes/soft_transform.json b/nodes/soft_transform.json
index 0f6179e..1fd83f4 100644
--- a/nodes/soft_transform.json
+++ b/nodes/soft_transform.json
@@ -7,9 +7,9 @@
{ "name": "Input", "type": "node", "default": "", "description": "The node whose geometry is moved, with the move fading out smoothly around the affected region." },
{ "name": "Translation", "type": "float3", "default": "0.00:0.20:0.00", "description": "Offset in world units applied at full strength to the affected points, and faded by the falloff for points around them." },
{ "name": "Center", "type": "float3", "default": "0.00:0.00:0.00", "description": "Point in world space the falloff is measured from when no Group is given. Points within Radius of it are moved." },
- { "name": "Group", "type": "group", "default": "", "description": "Point group to move at full strength; the falloff is measured from its nearest member rather than from Center. Empty uses Center." },
- { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01, "description": "Distance in world units over which the move fades to nothing. Points farther than this from the center or group are not moved." },
- { "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth", "description": "Shape of the fade with distance. Smooth eases in with no crease, Linear falls off evenly, and Constant moves everything inside the radius fully." },
- { "name": "Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute to write each point's falloff weight to, from 1 at the center to 0 at the radius. Empty writes nothing." }
+ { "name": "Group", "type": "group", "default": "", "description": "Point group to move at full strength; the falloff is measured from its nearest member rather than from Center. Empty uses Center.", "group": "where" },
+ { "name": "Radius", "type": "slider", "default": "0.50", "min": 0.0, "max": 10.0, "step": 0.01, "description": "Distance in world units over which the move fades to nothing. Points farther than this from the center or group are not moved.", "group": "falloff" },
+ { "name": "Falloff", "type": "choice:Smooth,Linear,Constant", "default": "Smooth", "description": "Shape of the fade with distance. Smooth eases in with no crease, Linear falls off evenly, and Constant moves everything inside the radius fully.", "group": "falloff" },
+ { "name": "Attribute", "type": "attribute", "default": "", "description": "Name of a point attribute to write each point's falloff weight to, from 1 at the center to 0 at the radius. Empty writes nothing.", "group": "output" }
]
}
diff --git a/nodes/sphere.json b/nodes/sphere.json
index 0b8e142..4bd290b 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -24,7 +24,8 @@
"max": 128,
"step": 1,
"show_when": "Method == UV",
- "description": "Number of latitude bands from pole to pole for the UV method. More rows give a smoother profile and more points."
+ "description": "Number of latitude bands from pole to pole for the UV method. More rows give a smoother profile and more points.",
+ "group": "resolution"
},
{
"name": "Columns",
@@ -34,7 +35,8 @@
"max": 128,
"step": 1,
"show_when": "Method == UV",
- "description": "Number of longitude segments around the UV sphere. More columns give a rounder silhouette and more points."
+ "description": "Number of longitude segments around the UV sphere. More columns give a rounder silhouette and more points.",
+ "group": "resolution"
},
{
"name": "Frequency",
@@ -44,7 +46,8 @@
"max": 16,
"step": 1,
"show_when": "Method == Icosphere",
- "description": "How many times each icosahedron edge is divided for the Icosphere method; each of the 20 faces becomes Frequency squared triangles."
+ "description": "How many times each icosahedron edge is divided for the Icosphere method; each of the 20 faces becomes Frequency squared triangles.",
+ "group": "resolution"
},
{
"name": "Resolution",
@@ -54,31 +57,36 @@
"max": 64,
"step": 1,
"show_when": "Method == Cube",
- "description": "Grid divisions along each edge of every cube face for the Cube method; each face gets Resolution squared quads."
+ "description": "Grid divisions along each edge of every cube face for the Cube method; each face gets Resolution squared quads.",
+ "group": "resolution"
},
{
"name": "Center X",
"default": "0.0",
"type": "slider:-2:2",
- "description": "X position of the sphere's center in world units."
+ "description": "X position of the sphere's center in world units.",
+ "group": "place"
},
{
"name": "Center Y",
"default": "0.55",
"type": "slider:-2:2",
- "description": "Y (height) position of the sphere's center in world units."
+ "description": "Y (height) position of the sphere's center in world units.",
+ "group": "place"
},
{
"name": "Center Z",
"default": "0.0",
"type": "slider:-2:2",
- "description": "Z position of the sphere's center in world units."
+ "description": "Z position of the sphere's center in world units.",
+ "group": "place"
},
{
"name": "Color",
"default": "true",
"type": "toggle",
- "description": "When on, colors each point by its surface direction (normal mapped to RGB); when off, the sphere uses the plain default color."
+ "description": "When on, colors each point by its surface direction (normal mapped to RGB); when off, the sphere uses the plain default color.",
+ "group": "look"
}
]
}
diff --git a/nodes/suture.json b/nodes/suture.json
index 346e994..58416a0 100644
--- a/nodes/suture.json
+++ b/nodes/suture.json
@@ -8,6 +8,6 @@
{ "name": "Against", "type": "node", "default": "", "description": "The geometry the input is kept apart from, read as a surface. With nothing connected no contact is found and nothing is pushed." },
{ "name": "Distance Threshold", "type": "slider", "default": "0.05", "min": 0.0, "max": 1.0, "step": 0.005, "description": "Contact distance in world units: nearer points are pushed back out to it, and fusing points within it merge. 0 turns the node off." },
{ "name": "Fusion Threshold", "type": "spinbox", "default": "3", "min": 1.0, "max": 100.0, "step": 1.0, "description": "How many evaluations in a row a point must stay in contact, usually simulation steps, before it can fuse with nearby fusing points. Higher fuses later." },
- { "name": "Counter", "type": "attribute", "default": "contact", "description": "Integer point attribute counting consecutive steps in contact; it resets to 0 when contact breaks. Empty turns the node off." }
+ { "name": "Counter", "type": "attribute", "default": "contact", "description": "Integer point attribute counting consecutive steps in contact; it resets to 0 when contact breaks. Empty turns the node off.", "group": "output" }
]
}
diff --git a/nodes/time.json b/nodes/time.json
index d6c9a3a..97b97bd 100644
--- a/nodes/time.json
+++ b/nodes/time.json
@@ -6,8 +6,8 @@
"params": [
{ "name": "Input", "type": "node", "default": "", "description": "The geometry that passes through with a time detail attribute added." },
{ "name": "Attribute", "type": "attribute", "default": "t", "description": "Name of the detail attribute written with the 0 to 1 progress through the frame range. Empty writes nothing." },
- { "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 0." },
- { "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 1. If equal to Start Frame, it jumps from 0 to 1 at that frame." },
- { "name": "Clamp", "type": "choice:true,false", "default": "true", "description": "When true the value stays between 0 and 1 outside the range. When false it keeps running, below 0 before the start and above 1 after the end." }
+ { "name": "Start Frame", "type": "spinbox", "default": "1", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 0.", "group": "range" },
+ { "name": "End Frame", "type": "spinbox", "default": "100", "min": -9999.0, "max": 9999.0, "step": 1.0, "description": "The frame at which the attribute reads 1. If equal to Start Frame, it jumps from 0 to 1 at that frame.", "group": "range" },
+ { "name": "Clamp", "type": "choice:true,false", "default": "true", "description": "When true the value stays between 0 and 1 outside the range. When false it keeps running, below 0 before the start and above 1 after the end.", "group": "range" }
]
}
diff --git a/nodes/transfer.json b/nodes/transfer.json
index 68ab5dc..d28c8a9 100644
--- a/nodes/transfer.json
+++ b/nodes/transfer.json
@@ -26,14 +26,16 @@
"name": "Transfer Groups",
"type": "toggle",
"default": "false",
- "description": "Also copies point group membership from the nearest source point, so points join or leave each group to match the source."
+ "description": "Also copies point group membership from the nearest source point, so points join or leave each group to match the source.",
+ "group": "groups"
},
{
"name": "Groups",
"type": "text",
"default": "",
"show_when": "Transfer Groups == true",
- "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has."
+ "description": "Comma-separated names of the point groups to copy. Empty copies every point group the source has.",
+ "group": "groups"
},
{
"name": "Maximum Distance",
@@ -42,13 +44,15 @@
"min": 0.0,
"max": 10.0,
"step": 0.01,
- "description": "Points further than this from any source point, in world units, receive nothing. 0 means no limit."
+ "description": "Points further than this from any source point, in world units, receive nothing. 0 means no limit.",
+ "group": "reach"
},
{
"name": "Group",
"type": "group",
"default": "",
- "description": "Only points of the input in this group receive values. Empty lets every point receive."
+ "description": "Only points of the input in this group receive values. Empty lets every point receive.",
+ "group": "where"
}
]
}
diff --git a/nodes/visualize.json b/nodes/visualize.json
index 6c05f59..1f687c6 100644
--- a/nodes/visualize.json
+++ b/nodes/visualize.json
@@ -27,35 +27,40 @@
"type": "choice:Grayscale,Heat,Spectrum,Viridis",
"default": "Viridis",
"show_when": "Mode == Ramp",
- "description": "The colour scale values are mapped through, from the low end to the high end."
+ "description": "The colour scale values are mapped through, from the low end to the high end.",
+ "group": "ramp"
},
{
"name": "Range",
"type": "choice:Auto,Manual",
"default": "Auto",
"show_when": "Mode == Ramp",
- "description": "Auto spreads the ramp from the attribute's lowest to highest value over all points; Manual uses From and To."
+ "description": "Auto spreads the ramp from the attribute's lowest to highest value over all points; Manual uses From and To.",
+ "group": "ramp"
},
{
"name": "From",
"type": "float",
"default": "0.00",
"show_when": "Mode == Ramp && Range == Manual",
- "description": "The value shown at the low end of the ramp. Values below it are clamped to that end."
+ "description": "The value shown at the low end of the ramp. Values below it are clamped to that end.",
+ "group": "ramp"
},
{
"name": "To",
"type": "float",
"default": "1.00",
"show_when": "Mode == Ramp && Range == Manual",
- "description": "The value shown at the high end of the ramp. Values above it are clamped to that end."
+ "description": "The value shown at the high end of the ramp. Values above it are clamped to that end.",
+ "group": "ramp"
},
{
"name": "Blend",
"type": "choice:Set,Multiply,Add",
"default": "Set",
"show_when": "Mode == Ramp",
- "description": "How the ramp colour meets the existing colour: Set replaces it, Multiply tints it, Add brightens it. Opacity then fades toward the result."
+ "description": "How the ramp colour meets the existing colour: Set replaces it, Multiply tints it, Add brightens it. Opacity then fades toward the result.",
+ "group": "ramp"
},
{
"name": "Opacity",
@@ -65,7 +70,8 @@
"max": 1.0,
"step": 0.01,
"show_when": "Mode == Ramp",
- "description": "How far each point's colour moves from its old colour toward the blended ramp colour: 1 is fully, 0 not at all."
+ "description": "How far each point's colour moves from its old colour toward the blended ramp colour: 1 is fully, 0 not at all.",
+ "group": "ramp"
},
{
"name": "Scale",
@@ -75,13 +81,15 @@
"max": 2.0,
"step": 0.01,
"show_when": "Mode == Vector",
- "description": "Multiplies the attribute's vector to get the drawn line's length, in world units per unit of the attribute."
+ "description": "Multiplies the attribute's vector to get the drawn line's length, in world units per unit of the attribute.",
+ "group": "vector"
},
{
"name": "Group",
"type": "group",
"default": "",
- "description": "A point group limiting which points are coloured or get a line. Empty means every point; Auto range still measures all points."
+ "description": "A point group limiting which points are coloured or get a line. Empty means every point; Auto range still measures all points.",
+ "group": "where"
}
]
}
diff --git a/nodes/wrangle.json b/nodes/wrangle.json
index 6fc458c..88ba921 100644
--- a/nodes/wrangle.json
+++ b/nodes/wrangle.json
@@ -11,7 +11,8 @@
"name": "Code",
"type": "code",
"default": "// Runs once per point. @P is the position, @name any attribute;\n// ch(\"Name\") reads a parameter, neighbours(@ptnum) the topology.\n@P.y += sin(@P.x * 4.0) * 0.15;\n",
- "description": "The Rhai script run per element. @name reads or writes an attribute, ch(\"path\") reads a parameter. Applies on ctrl+enter or when leaving the editor; an error leaves the input unchanged."
+ "description": "The Rhai script run per element. @name reads or writes an attribute, ch(\"path\") reads a parameter. Applies on ctrl+enter or when leaving the editor; an error leaves the input unchanged.",
+ "group": "code"
}
]
}
diff --git a/src/app.rs b/src/app.rs
index 1a63b20..cabac52 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -898,11 +898,57 @@ pub fn node_wires(node: &FsNode) -> Vec<(String, String)> {
.collect()
}
+/// The run a parameter belongs to, for the pane's separators: the
+/// template's `group`, or for the LEADING run of wires — the node's inputs
+/// at the top of its list — a group of their own, so every node with an
+/// input has a line under it without a template saying so. A wire further
+/// down (Relax's Rest, under its Mode) is part of whatever run it is in.
+pub fn param_group(params: &[ParamDef], i: usize) -> String {
+ let p = ¶ms[i];
+ if !p.group.is_empty() {
+ return p.group.clone();
+ }
+ let leading_wire = params[..=i].iter().all(|q| q.kind() == ParamKind::Node && q.group.is_empty());
+ if leading_wire { PARAM_INPUTS_GROUP.to_string() } else { String::new() }
+}
+
+/// The group the leading wires are in when a template names none.
+pub const PARAM_INPUTS_GROUP: &str = "inputs";
+
+/// A separator row in `param_display`'s output: cce-ui's `separator` row,
+/// with no key and no value, so the write-back finds no parameter by it.
+pub const PARAM_SEPARATOR: &str = "separator";
+
+/// The params pane's rows: every SHOWN parameter as (display key, value,
+/// row type), with a separator row wherever two shown rows belong to
+/// different groups ([`param_group`]) — between the inputs and the rest,
+/// and between runs of parameters about different things. Never first or
+/// last, and never two in a row, since one is only ever placed between two
+/// shown parameters.
pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// Rows whose condition does not hold are not shown. Write-back resolves a
// row by its display key rather than by position, so a hidden parameter
// simply is not reported and keeps whatever value it had.
- params.iter().filter(|p| param_visible(params, &p.show_when)).map(|p| {
+ let mut out = Vec::new();
+ let mut last_group: Option<String> = None;
+ for (i, p) in params.iter().enumerate() {
+ if !param_visible(params, &p.show_when) {
+ continue;
+ }
+ let group = param_group(params, i);
+ if last_group.as_ref().is_some_and(|g| *g != group) {
+ out.push((String::new(), String::new(), PARAM_SEPARATOR.to_string()));
+ }
+ last_group = Some(group);
+ out.push(param_row(p));
+ }
+ out
+}
+
+/// One parameter as the pane's row: its display key, its value as the row
+/// shows it, and the row type.
+fn param_row(p: &ParamDef) -> (String, String, String) {
+ {
let key = if p.label.is_empty() { &p.name } else { &p.label };
let kind = p.kind();
let value = if p.ty() == "choice" && !p.options().is_empty() && p.text().is_empty() {
@@ -940,7 +986,7 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
p.ty().to_string()
};
(key.clone(), value, ptype)
- }).collect()
+ }
}
pub fn flatten_node_templates(root: &FsNode) -> Vec<NodeTemplate> {
diff --git a/src/main.rs b/src/main.rs
index e81ee28..e5d95d8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4742,8 +4742,9 @@ mod tests {
o.entry(k).or_insert(Value::Null);
}
o.entry("show_when").or_insert("".into());
- // A template's description is read and never written.
+ // A template's description and group are read and never written.
o.remove("description");
+ o.remove("group");
if o.get("expr") == Some(&Value::Bool(false)) {
o.remove("expr");
}
@@ -5091,7 +5092,47 @@ mod tests {
let row = |ty: &str| crate::app::ParamDef::new("X", ty, "1");
let shown = crate::app::param_display(&[row("node"), row("float"), row("string"), row("attribute"), row("group"), row("toggle")]);
let types: Vec<&str> = shown.iter().map(|r| r.2.as_str()).collect();
- assert_eq!(types, vec!["text", "text", "text", "text", "text", "toggle"]);
+ // The leading wire is the inputs, a separator under it.
+ assert_eq!(types, vec!["text", "separator", "text", "text", "text", "text", "toggle"]);
+ }
+
+ /// The params pane draws a separator wherever two rows it shows belong to
+ /// different groups: under the leading wires, and between the runs a
+ /// template names. Only between two SHOWN rows — never first, last or
+ /// doubled, and a run whose rows are all hidden leaves no line — and a
+ /// wire further down is part of the run it is in. The separator names
+ /// no parameter, so the write-back passes over it.
+ #[test]
+ fn the_params_pane_separates_groups_of_parameters() {
+ let p = |name: &str, ty: &str, group: &str, show_when: &str| {
+ let mut d = crate::app::ParamDef::new(name, ty, "1");
+ d.group = group.to_string();
+ d.show_when = show_when.to_string();
+ d
+ };
+ let rows = |ps: &[crate::app::ParamDef]| -> Vec<String> {
+ crate::app::param_display(ps).into_iter().map(|r| if r.2 == "separator" { "|".to_string() } else { r.0 }).collect()
+ };
+ let params = vec![
+ p("Input", "node", "", ""),
+ p("With", "node", "", ""),
+ p("Mode", "choice:A,B", "", ""),
+ p("Rest", "node", "", ""),
+ p("Size", "float", "shape", ""),
+ p("Sides", "float", "shape", "Mode == B"),
+ p("Hidden", "float", "extra", "Mode == B"),
+ p("Group", "group", "where", ""),
+ ];
+ assert_eq!(rows(¶ms), ["Input", "With", "|", "Mode", "Rest", "|", "Size", "|", "Group"]);
+ // A node with no wires and no groups has no line at all.
+ assert_eq!(rows(&[p("A", "float", "", ""), p("B", "float", "", "")]), ["A", "B"]);
+ // A template-named group on a wire takes it out of the inputs.
+ assert_eq!(rows(&[p("Input", "node", "", ""), p("Surface", "node", "x", ""), p("Size", "float", "x", "")]), ["Input", "|", "Surface", "Size"]);
+ // The group is the template's: it rides the merge, never the file.
+ let mut inst = crate::app::ParamDef::new("Size", "float", "2");
+ inst.adopt_ui_from(¶ms[4]);
+ assert_eq!(inst.group, "shape");
+ assert!(!serde_json::to_string(&inst).unwrap().contains("shape"), "not written");
}
/// Phase 2's toggle reader: the words a toggle can hold, in any case,
diff --git a/src/param.rs b/src/param.rs
index 4ebee13..ee7b7b4 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -203,6 +203,12 @@ pub struct ParamDef {
/// description kept there would go stale the day the template's
/// wording changed (and move `sim_solve_key` the day it did).
pub description: String,
+ /// Which run of related parameters this one belongs to, by name — the
+ /// params pane draws a separator wherever two rows it shows belong to
+ /// different groups (`param_display`). The TEMPLATE's, read and never
+ /// written, as the description is. Empty is the node's ungrouped run;
+ /// the leading wires are a group of their own without saying so.
+ pub group: String,
}
/// The file shape of a [`ParamDef`] — the struct as it was before the value
@@ -241,6 +247,9 @@ struct ParamDefRepr {
/// [`ParamDef::description`]; read, never written.
#[serde(default, skip_serializing)]
description: String,
+ /// [`ParamDef::group`]; read, never written.
+ #[serde(default, skip_serializing)]
+ group: String,
}
fn default_param_type() -> String {
@@ -263,6 +272,7 @@ impl<'de> Deserialize<'de> for ParamDef {
show_when: r.show_when,
view: r.view,
description: r.description,
+ group: r.group,
};
if !r.expr {
p.reparse();
@@ -286,6 +296,7 @@ impl Serialize for ParamDef {
expr: self.is_expr(),
view: self.view.clone(),
description: String::new(),
+ group: String::new(),
}
.serialize(s)
}
@@ -355,6 +366,7 @@ impl ParamDef {
show_when: String::new(),
view: String::new(),
description: String::new(),
+ group: String::new(),
};
p.reparse();
p
@@ -574,6 +586,7 @@ impl ParamDef {
self.step = template.step;
self.show_when = template.show_when.clone();
self.description = template.description.clone();
+ self.group = template.group.clone();
// The view is the instance's to choose; the template's is what it
// starts from.
if self.view.is_empty() {