Parametric curve plot#4313
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new Function Plot shape tool, allowing users to plot parametric curves using mathematical expressions. It adds a new function_plot node that handles expression parsing, adaptive sampling, discontinuity detection, and axis generation, while also expanding the math-parser library with various new mathematical functions and constants. The review feedback highlights several critical issues: a mathematically incorrect implementation of acot, potential silent dropping of curve intervals during sampling, incorrect treatment of mathematical constants as variables, and potential division-by-zero errors when calculating transforms for flat dimensions. Additionally, minor improvements are suggested to reset continuity states across invalid points, simplify import paths, and avoid redundant matrix inversions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| "acot", | ||
| Box::new(|values| match values { | ||
| [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real((PI / 2. - real).atan()))), | ||
| [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex((Complex::new(PI / 2., 0.) - complex).atan()))), | ||
| _ => None, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
The implementation of acot (arccotangent) is mathematically incorrect. It is currently implemented as (PI / 2. - real).atan(), which computes
map.insert(
"acot",
Box::new(|values| match values {
[Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(PI / 2. - real.atan()))),
[Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(Complex::new(PI / 2., 0.) - complex.atan()))),
_ => None,
}),
);| if current_depth > min_depth && begin_point.is_valid() && end_point.is_valid() { | ||
| let t_quarter = t_begin + (t_end - t_begin) * 0.25; | ||
| let t_q3 = t_begin + (t_end - t_begin) * 0.75; | ||
|
|
||
| let Some(point_q1) = evaluate_expressions(t_quarter, x_node, y_node, variable_name) else { | ||
| return; | ||
| }; | ||
|
|
||
| let Some(point_q3) = evaluate_expressions(t_q3, x_node, y_node, variable_name) else { | ||
| return; | ||
| }; | ||
|
|
||
| let Some(begin_coord) = begin_point.coord() else { return }; | ||
| let Some(end_coord) = end_point.coord() else { return }; | ||
|
|
||
| let segment_length = begin_coord.distance(end_coord); | ||
| let tolerance = segment_length * TOLERANCE_FACTOR; | ||
|
|
||
| let segment_q1 = begin_coord + (end_coord - begin_coord) * 0.25; | ||
| let segment_mid = (begin_coord + end_coord) * 0.5; | ||
| let segment_q3 = begin_coord + (end_coord - begin_coord) * 0.75; | ||
|
|
||
| let mut max_error: f64 = 0.; | ||
|
|
||
| if let Some(q1) = point_q1.coord() { | ||
| max_error = max_error.max(q1.distance(segment_q1)); | ||
| } | ||
| if let Some(mid) = point_mid.coord() { | ||
| max_error = max_error.max(mid.distance(segment_mid)); | ||
| } | ||
| if let Some(q3) = point_q3.coord() { | ||
| max_error = max_error.max(q3.distance(segment_q3)); | ||
| } | ||
|
|
||
| if max_error < tolerance { | ||
| update_bounds(&begin_point, bounds); | ||
| sample_points.push(begin_point); | ||
| return; | ||
| } | ||
| } |
There was a problem hiding this comment.
In sample_interval, if evaluate_expressions returns None for either of the quarter points (t_quarter or t_q3), the function returns early without pushing begin_point to sample_points and without recursing further. This causes the entire interval to be silently dropped. Instead of returning early, the function should proceed to the recursive step if the quarter points cannot be evaluated.
if current_depth > min_depth && begin_point.is_valid() && end_point.is_valid() {
let t_quarter = t_begin + (t_end - t_begin) * 0.25;
let t_q3 = t_begin + (t_end - t_begin) * 0.75;
if let (Some(point_q1), Some(point_q3)) = (
evaluate_expressions(t_quarter, x_node, y_node, variable_name),
evaluate_expressions(t_q3, x_node, y_node, variable_name),
) {
let Some(begin_coord) = begin_point.coord() else { return };
let Some(end_coord) = end_point.coord() else { return };
let segment_length = begin_coord.distance(end_coord);
let tolerance = segment_length * TOLERANCE_FACTOR;
let segment_q1 = begin_coord + (end_coord - begin_coord) * 0.25;
let segment_mid = (begin_coord + end_coord) * 0.5;
let segment_q3 = begin_coord + (end_coord - begin_coord) * 0.75;
let mut max_error: f64 = 0.;
if let Some(q1) = point_q1.coord() {
max_error = max_error.max(q1.distance(segment_q1));
}
if let Some(mid) = point_mid.coord() {
max_error = max_error.max(mid.distance(segment_mid));
}
if let Some(q3) = point_q3.coord() {
max_error = max_error.max(q3.distance(segment_q3));
}
if max_error < tolerance {
update_bounds(&begin_point, bounds);
sample_points.push(begin_point);
return;
}
}
}| fn collect_variables(node: &ast::Node, vars: &mut HashSet<String>) { | ||
| match node { | ||
| ast::Node::Var(name) => { | ||
| vars.insert(name.clone()); | ||
| } |
There was a problem hiding this comment.
In collect_variables, mathematical constants like pi, tau, e, phi, and G are collected as variables because they are parsed as ast::Node::Var. This causes expressions like sin(t + pi) to be rejected because the parser thinks there are multiple variables (t and pi). We should filter out these known constants in collect_variables.
| fn collect_variables(node: &ast::Node, vars: &mut HashSet<String>) { | |
| match node { | |
| ast::Node::Var(name) => { | |
| vars.insert(name.clone()); | |
| } | |
| fn collect_variables(node: &ast::Node, vars: &mut HashSet<String>) { | |
| match node { | |
| ast::Node::Var(name) => { | |
| if !matches!(name.as_str(), "pi" | "tau" | "e" | "phi" | "G") { | |
| vars.insert(name.clone()); | |
| } | |
| } |
| for i in 0..curve_points.len() { | ||
| let current_point = &curve_points[i]; | ||
| if !current_point.is_valid() { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
In detect_curve_segments, if there is an invalid point in the curve, the loop skips it using continue. However, previous_right_continuity is not reset. This means the continuity state from before the invalid point can leak and cause disjoint segments to be incorrectly merged across the undefined region. We should reset previous_right_continuity when transitioning past an invalid point.
for i in 0..curve_points.len() {
let current_point = &curve_points[i];
if !current_point.is_valid() {
continue;
}
if i > 0 && !curve_points[i - 1].is_valid() {
previous_right_continuity = Some(true);
}| fn calculate_plot_transform(bounds: &CurveBounds, width: f64, height: f64) -> PlotTransform { | ||
| let x_scale = width / (bounds.x_max - bounds.x_min).max(f64::EPSILON); | ||
| let y_scale = height / (bounds.y_max - bounds.y_min).max(f64::EPSILON); | ||
|
|
||
| let scale = x_scale.min(y_scale); | ||
|
|
||
| let x_center = (bounds.x_min + bounds.x_max) / 2.; | ||
| let y_center = (bounds.y_min + bounds.y_max) / 2.; | ||
|
|
||
| PlotTransform { scale, x_center, y_center } | ||
| } |
There was a problem hiding this comment.
In calculate_plot_transform, if the bounds are flat in one or both dimensions (e.g., a constant function like y = 5 or a single point), the scale calculation can blow up to infinity or cause floating-point overflow due to division by f64::EPSILON. We should handle flat dimensions robustly by defaulting to 1.0 if both are flat, or using f64::INFINITY for a single flat dimension so that min() naturally selects the scale of the non-flat dimension.
| fn calculate_plot_transform(bounds: &CurveBounds, width: f64, height: f64) -> PlotTransform { | |
| let x_scale = width / (bounds.x_max - bounds.x_min).max(f64::EPSILON); | |
| let y_scale = height / (bounds.y_max - bounds.y_min).max(f64::EPSILON); | |
| let scale = x_scale.min(y_scale); | |
| let x_center = (bounds.x_min + bounds.x_max) / 2.; | |
| let y_center = (bounds.y_min + bounds.y_max) / 2.; | |
| PlotTransform { scale, x_center, y_center } | |
| } | |
| fn calculate_plot_transform(bounds: &CurveBounds, width: f64, height: f64) -> PlotTransform { | |
| let x_range = bounds.x_max - bounds.x_min; | |
| let y_range = bounds.y_max - bounds.y_min; | |
| let scale = if x_range < 1e-9 && y_range < 1e-9 { | |
| 1.0 | |
| } else { | |
| let x_scale = if x_range < 1e-9 { f64::INFINITY } else { width / x_range }; | |
| let y_scale = if y_range < 1e-9 { f64::INFINITY } else { height / y_range }; | |
| x_scale.min(y_scale) | |
| }; | |
| let x_center = (bounds.x_min + bounds.x_max) / 2.; | |
| let y_center = (bounds.y_min + bounds.y_max) / 2.; | |
| PlotTransform { scale, x_center, y_center } | |
| } |
| pub fn create_node() -> NodeTemplate { | ||
| let node_type = resolve_proto_node_type(graphene_std::vector::plot_nodes::function_plot::IDENTIFIER).expect("Function Plot node can't be found"); | ||
| node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(0.5), false)), Some(NodeInput::value(TaggedValue::F64(0.5), false))]) | ||
| } |
There was a problem hiding this comment.
Using the fully qualified path graphene_std::vector::plot_nodes::function_plot::IDENTIFIER is unnecessary and inconsistent with other shapes. Since plot_nodes is re-exported at the root of vector, we can use graphene_std::vector::function_plot::IDENTIFIER directly.
| pub fn create_node() -> NodeTemplate { | |
| let node_type = resolve_proto_node_type(graphene_std::vector::plot_nodes::function_plot::IDENTIFIER).expect("Function Plot node can't be found"); | |
| node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(0.5), false)), Some(NodeInput::value(TaggedValue::F64(0.5), false))]) | |
| } | |
| pub fn create_node() -> NodeTemplate { | |
| let node_type = resolve_proto_node_type(graphene_std::vector::function_plot::IDENTIFIER).expect("Function Plot node can't be found"); | |
| node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(0.5), false)), Some(NodeInput::value(TaggedValue::F64(0.5), false))]) | |
| } |
| let start = document_to_viewport.inverse().transform_point2(start); | ||
| let end = document_to_viewport.inverse().transform_point2(end); |
There was a problem hiding this comment.
We can avoid computing the inverse of document_to_viewport twice by storing it in a local variable.
| let start = document_to_viewport.inverse().transform_point2(start); | |
| let end = document_to_viewport.inverse().transform_point2(end); | |
| let viewport_to_document = document_to_viewport.inverse(); | |
| let start = viewport_to_document.transform_point2(start); | |
| let end = viewport_to_document.transform_point2(end); |
|
Here is a video about the current functionality: functionplot-2026-07-06_16.07.20.mp4 |
This adds a simple parametric curve plot to Graphite.
This algorithm is designed to be simple it allows the user to add simple function plots to the document. It has no intention to compete with other more professional plotting software, like Desmos.
It uses an adaptive sampling (instead of fixed one), because in this way the plot looks better with less points and also the discontinuity detection works better too.
Known limitations:
Part of: #2458