Skip to content

Parametric curve plot#4313

Draft
devviktoria wants to merge 8 commits into
GraphiteEditor:masterfrom
devviktoria:function-plot
Draft

Parametric curve plot#4313
devviktoria wants to merge 8 commits into
GraphiteEditor:masterfrom
devviktoria:function-plot

Conversation

@devviktoria

@devviktoria devviktoria commented Jul 6, 2026

Copy link
Copy Markdown

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:

  • at most one variable is currently supported in the expressions
  • there are no symbolic analysis for precise function domain detection, we are figuring out the domain by evaluating the given expressions
  • the discontinuity detection is also happening by analysing the sampled data; it may detect false positives/false negatives
  • there are no periodicity detection, since we are doing a simple adaptive sampling

Part of: #2458

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +112 to 118
"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,
}),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The implementation of acot (arccotangent) is mathematically incorrect. It is currently implemented as (PI / 2. - real).atan(), which computes $\arctan(\frac{\pi}{2} - x)$. However, the correct mathematical definition of arccotangent is $\operatorname{arccot}(x) = \frac{\pi}{2} - \arctan(x)$. This should be corrected for both real and complex numbers.

		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,
			}),
		);

Comment on lines +268 to +307
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
			}
		}
	}

Comment on lines +565 to +569
fn collect_variables(node: &ast::Node, vars: &mut HashSet<String>) {
match node {
ast::Node::Var(name) => {
vars.insert(name.clone());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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());
}
}

Comment on lines +366 to +370
for i in 0..curve_points.len() {
let current_point = &curve_points[i];
if !current_point.is_valid() {
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
		}

Comment on lines +482 to +492
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 }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 }
}

Comment on lines +18 to +21
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))])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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))])
}

Comment on lines +44 to +45
let start = document_to_viewport.inverse().transform_point2(start);
let end = document_to_viewport.inverse().transform_point2(end);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can avoid computing the inverse of document_to_viewport twice by storing it in a local variable.

Suggested change
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);

@devviktoria

Copy link
Copy Markdown
Author

Here is a video about the current functionality:

functionplot-2026-07-06_16.07.20.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant