Преглед на файлове

Extend `Curve` to allow for arbitrary domains

ocean (they/them) преди 2 години
родител
ревизия
6c65213487

+ 2 - 2
doc/classes/AnimationNodeOneShot.xml

@@ -70,14 +70,14 @@
 			If [code]true[/code], breaks the loop at the end of the loop cycle for transition, even if the animation is looping.
 		</member>
 		<member name="fadein_curve" type="Curve" setter="set_fadein_curve" getter="get_fadein_curve">
-			Determines how cross-fading between animations is eased. If empty, the transition will be linear.
+			Determines how cross-fading between animations is eased. If empty, the transition will be linear. Should be a unit [Curve].
 		</member>
 		<member name="fadein_time" type="float" setter="set_fadein_time" getter="get_fadein_time" default="0.0">
 			The fade-in duration. For example, setting this to [code]1.0[/code] for a 5 second length animation will produce a cross-fade that starts at 0 second and ends at 1 second during the animation.
 			[b]Note:[/b] [AnimationNodeOneShot] transitions the current state after the end of the fading. When [AnimationNodeOutput] is considered as the most upstream, so the [member fadein_time] is scaled depending on the downstream delta. For example, if this value is set to [code]1.0[/code] and a [AnimationNodeTimeScale] with a value of [code]2.0[/code] is chained downstream, the actual processing time will be 0.5 second.
 		</member>
 		<member name="fadeout_curve" type="Curve" setter="set_fadeout_curve" getter="get_fadeout_curve">
-			Determines how cross-fading between animations is eased. If empty, the transition will be linear.
+			Determines how cross-fading between animations is eased. If empty, the transition will be linear. Should be a unit [Curve].
 		</member>
 		<member name="fadeout_time" type="float" setter="set_fadeout_time" getter="get_fadeout_time" default="0.0">
 			The fade-out duration. For example, setting this to [code]1.0[/code] for a 5 second length animation will produce a cross-fade that starts at 4 second and ends at 5 second during the animation.

+ 1 - 1
doc/classes/AnimationNodeStateMachineTransition.xml

@@ -41,7 +41,7 @@
 			The transition type.
 		</member>
 		<member name="xfade_curve" type="Curve" setter="set_xfade_curve" getter="get_xfade_curve">
-			Ease curve for better control over cross-fade between this state and the next.
+			Ease curve for better control over cross-fade between this state and the next. Should be a unit [Curve].
 		</member>
 		<member name="xfade_time" type="float" setter="set_xfade_time" getter="get_xfade_time" default="0.0">
 			The time to cross-fade between this state and the next.

+ 1 - 1
doc/classes/AnimationNodeTransition.xml

@@ -96,7 +96,7 @@
 			The number of enabled input ports for this animation node.
 		</member>
 		<member name="xfade_curve" type="Curve" setter="set_xfade_curve" getter="get_xfade_curve">
-			Determines how cross-fading between animations is eased. If empty, the transition will be linear.
+			Determines how cross-fading between animations is eased. If empty, the transition will be linear. Should be a unit [Curve].
 		</member>
 		<member name="xfade_time" type="float" setter="set_xfade_time" getter="get_xfade_time" default="0.0">
 			Cross-fading time (in seconds) between each animation connected to the inputs.

+ 14 - 14
doc/classes/CPUParticles2D.xml

@@ -57,7 +57,7 @@
 			<param index="0" name="param" type="int" enum="CPUParticles2D.Parameter" />
 			<param index="1" name="curve" type="Curve" />
 			<description>
-				Sets the [Curve] of the parameter specified by [enum Parameter].
+				Sets the [Curve] of the parameter specified by [enum Parameter]. Should be a unit [Curve].
 			</description>
 		</method>
 		<method name="set_param_max">
@@ -90,7 +90,7 @@
 			Number of particles emitted in one emission cycle.
 		</member>
 		<member name="angle_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's rotation will be animated along this [Curve].
+			Each particle's rotation will be animated along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="angle_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum initial rotation applied to each particle, in degrees.
@@ -99,7 +99,7 @@
 			Minimum equivalent of [member angle_max].
 		</member>
 		<member name="angular_velocity_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's angular velocity will vary along this [Curve].
+			Each particle's angular velocity will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="angular_velocity_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum initial angular velocity (rotation speed) applied to each particle in [i]degrees[/i] per second.
@@ -108,7 +108,7 @@
 			Minimum equivalent of [member angular_velocity_max].
 		</member>
 		<member name="anim_offset_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's animation offset will vary along this [Curve].
+			Each particle's animation offset will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="anim_offset_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum animation offset that corresponds to frame index in the texture. [code]0[/code] is the first frame, [code]1[/code] is the last one. See [member CanvasItemMaterial.particles_animation].
@@ -117,7 +117,7 @@
 			Minimum equivalent of [member anim_offset_max].
 		</member>
 		<member name="anim_speed_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's animation speed will vary along this [Curve].
+			Each particle's animation speed will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="anim_speed_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum particle animation speed. Animation speed of [code]1[/code] means that the particles will make full [code]0[/code] to [code]1[/code] offset cycle during lifetime, [code]2[/code] means [code]2[/code] cycles etc.
@@ -136,7 +136,7 @@
 			Each particle's color will vary along this [Gradient] (multiplied with [member color]).
 		</member>
 		<member name="damping_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Damping will vary along this [Curve].
+			Damping will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="damping_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			The maximum rate at which particles lose velocity. For example value of [code]100[/code] means that the particle will go from [code]100[/code] velocity to [code]0[/code] in [code]1[/code] second.
@@ -184,7 +184,7 @@
 			Gravity applied to every particle.
 		</member>
 		<member name="hue_variation_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's hue will vary along this [Curve].
+			Each particle's hue will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="hue_variation_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum initial hue variation applied to each particle. It will shift the particle color's hue.
@@ -205,7 +205,7 @@
 			Particle lifetime randomness ratio.
 		</member>
 		<member name="linear_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's linear acceleration will vary along this [Curve].
+			Each particle's linear acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="linear_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum linear acceleration applied to each particle in the direction of motion.
@@ -220,7 +220,7 @@
 			If [code]true[/code], only one emission cycle occurs. If set [code]true[/code] during a cycle, emission will stop at the cycle's end.
 		</member>
 		<member name="orbit_velocity_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's orbital velocity will vary along this [Curve].
+			Each particle's orbital velocity will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="orbit_velocity_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum orbital velocity applied to each particle. Makes the particles circle around origin. Specified in number of full rotations around origin per second.
@@ -235,7 +235,7 @@
 			Particle system starts as if it had already run for this many seconds.
 		</member>
 		<member name="radial_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's radial acceleration will vary along this [Curve].
+			Each particle's radial acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="radial_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum radial acceleration applied to each particle. Makes particle accelerate away from the origin or towards it if negative.
@@ -247,7 +247,7 @@
 			Emission lifetime randomness ratio.
 		</member>
 		<member name="scale_amount_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's scale will vary along this [Curve].
+			Each particle's scale will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="scale_amount_max" type="float" setter="set_param_max" getter="get_param_max" default="1.0">
 			Maximum initial scale applied to each particle.
@@ -256,11 +256,11 @@
 			Minimum equivalent of [member scale_amount_max].
 		</member>
 		<member name="scale_curve_x" type="Curve" setter="set_scale_curve_x" getter="get_scale_curve_x">
-			Each particle's horizontal scale will vary along this [Curve].
+			Each particle's horizontal scale will vary along this [Curve]. Should be a unit [Curve].
 			[member split_scale] must be enabled.
 		</member>
 		<member name="scale_curve_y" type="Curve" setter="set_scale_curve_y" getter="get_scale_curve_y">
-			Each particle's vertical scale will vary along this [Curve].
+			Each particle's vertical scale will vary along this [Curve]. Should be a unit [Curve].
 			[member split_scale] must be enabled.
 		</member>
 		<member name="speed_scale" type="float" setter="set_speed_scale" getter="get_speed_scale" default="1.0">
@@ -273,7 +273,7 @@
 			Each particle's initial direction range from [code]+spread[/code] to [code]-spread[/code] degrees.
 		</member>
 		<member name="tangential_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's tangential acceleration will vary along this [Curve].
+			Each particle's tangential acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="tangential_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum tangential acceleration applied to each particle. Tangential acceleration is perpendicular to the particle's velocity giving the particles a swirling motion.

+ 12 - 12
doc/classes/CPUParticles3D.xml

@@ -63,7 +63,7 @@
 			<param index="0" name="param" type="int" enum="CPUParticles3D.Parameter" />
 			<param index="1" name="curve" type="Curve" />
 			<description>
-				Sets the [Curve] of the parameter specified by [enum Parameter].
+				Sets the [Curve] of the parameter specified by [enum Parameter]. Should be a unit [Curve].
 			</description>
 		</method>
 		<method name="set_param_max">
@@ -96,7 +96,7 @@
 			Number of particles emitted in one emission cycle.
 		</member>
 		<member name="angle_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's rotation will be animated along this [Curve].
+			Each particle's rotation will be animated along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="angle_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum angle.
@@ -105,7 +105,7 @@
 			Minimum angle.
 		</member>
 		<member name="angular_velocity_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's angular velocity (rotation speed) will vary along this [Curve] over its lifetime.
+			Each particle's angular velocity (rotation speed) will vary along this [Curve] over its lifetime. Should be a unit [Curve].
 		</member>
 		<member name="angular_velocity_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum initial angular velocity (rotation speed) applied to each particle in [i]degrees[/i] per second.
@@ -114,7 +114,7 @@
 			Minimum initial angular velocity (rotation speed) applied to each particle in [i]degrees[/i] per second.
 		</member>
 		<member name="anim_offset_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's animation offset will vary along this [Curve].
+			Each particle's animation offset will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="anim_offset_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum animation offset.
@@ -123,7 +123,7 @@
 			Minimum animation offset.
 		</member>
 		<member name="anim_speed_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's animation speed will vary along this [Curve].
+			Each particle's animation speed will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="anim_speed_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum particle animation speed.
@@ -144,7 +144,7 @@
 			[b]Note:[/b] [member color_ramp] multiplies the particle mesh's vertex colors. To have a visible effect on a [BaseMaterial3D], [member BaseMaterial3D.vertex_color_use_as_albedo] [i]must[/i] be [code]true[/code]. For a [ShaderMaterial], [code]ALBEDO *= COLOR.rgb;[/code] must be inserted in the shader's [code]fragment()[/code] function. Otherwise, [member color_ramp] will have no visible effect.
 		</member>
 		<member name="damping_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Damping will vary along this [Curve].
+			Damping will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="damping_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum damping.
@@ -212,7 +212,7 @@
 			Gravity applied to every particle.
 		</member>
 		<member name="hue_variation_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's hue will vary along this [Curve].
+			Each particle's hue will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="hue_variation_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum hue variation.
@@ -233,7 +233,7 @@
 			Particle lifetime randomness ratio.
 		</member>
 		<member name="linear_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's linear acceleration will vary along this [Curve].
+			Each particle's linear acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="linear_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum linear acceleration.
@@ -251,7 +251,7 @@
 			If [code]true[/code], only one emission cycle occurs. If set [code]true[/code] during a cycle, emission will stop at the cycle's end.
 		</member>
 		<member name="orbit_velocity_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's orbital velocity will vary along this [Curve].
+			Each particle's orbital velocity will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="orbit_velocity_max" type="float" setter="set_param_max" getter="get_param_max">
 			Maximum orbit velocity.
@@ -272,7 +272,7 @@
 			Particle system starts as if it had already run for this many seconds.
 		</member>
 		<member name="radial_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's radial acceleration will vary along this [Curve].
+			Each particle's radial acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="radial_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum radial acceleration.
@@ -284,7 +284,7 @@
 			Emission lifetime randomness ratio.
 		</member>
 		<member name="scale_amount_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's scale will vary along this [Curve].
+			Each particle's scale will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="scale_amount_max" type="float" setter="set_param_max" getter="get_param_max" default="1.0">
 			Maximum scale.
@@ -311,7 +311,7 @@
 			Each particle's initial direction range from [code]+spread[/code] to [code]-spread[/code] degrees. Applied to X/Z plane and Y/Z planes.
 		</member>
 		<member name="tangential_accel_curve" type="Curve" setter="set_param_curve" getter="get_param_curve">
-			Each particle's tangential acceleration will vary along this [Curve].
+			Each particle's tangential acceleration will vary along this [Curve]. Should be a unit [Curve].
 		</member>
 		<member name="tangential_accel_max" type="float" setter="set_param_max" getter="get_param_max" default="0.0">
 			Maximum tangent acceleration.

+ 27 - 4
doc/classes/Curve.xml

@@ -4,8 +4,8 @@
 		A mathematical curve.
 	</brief_description>
 	<description>
-		This resource describes a mathematical curve by defining a set of points and tangents at each point. By default, it ranges between [code]0[/code] and [code]1[/code] on the Y axis and positions points relative to the [code]0.5[/code] Y position.
-		See also [Gradient] which is designed for color interpolation. See also [Curve2D] and [Curve3D].
+		This resource describes a mathematical curve by defining a set of points and tangents at each point. By default, it ranges between [code]0[/code] and [code]1[/code] on the X and Y axes, but these ranges can be changed.
+		Please note that many resources and nodes assume they are given [i]unit curves[/i]. A unit curve is a curve whose domain (the X axis) is between [code]0[/code] and [code]1[/code]. Some examples of unit curve usage are [member CPUParticles2D.angle_curve] and [member Line2D.width_curve].
 	</description>
 	<tutorials>
 	</tutorials>
@@ -39,6 +39,12 @@
 				Removes all points from the curve.
 			</description>
 		</method>
+		<method name="get_domain_range" qualifiers="const">
+			<return type="float" />
+			<description>
+				Returns the difference between [member min_domain] and [member max_domain].
+			</description>
+		</method>
 		<method name="get_point_left_mode" qualifiers="const">
 			<return type="int" enum="Curve.TangentMode" />
 			<param index="0" name="index" type="int" />
@@ -74,6 +80,12 @@
 				Returns the right tangent angle (in degrees) for the point at [param index].
 			</description>
 		</method>
+		<method name="get_value_range" qualifiers="const">
+			<return type="float" />
+			<description>
+				Returns the difference between [member min_value] and [member max_value].
+			</description>
+		</method>
 		<method name="remove_point">
 			<return type="void" />
 			<param index="0" name="index" type="int" />
@@ -148,17 +160,28 @@
 		<member name="bake_resolution" type="int" setter="set_bake_resolution" getter="get_bake_resolution" default="100">
 			The number of points to include in the baked (i.e. cached) curve data.
 		</member>
+		<member name="max_domain" type="float" setter="set_max_domain" getter="get_max_domain" default="1.0">
+			The maximum domain (x-coordinate) that points can have.
+		</member>
 		<member name="max_value" type="float" setter="set_max_value" getter="get_max_value" default="1.0">
-			The maximum value the curve can reach.
+			The maximum value (y-coordinate) that points can have. Tangents can cause higher values between points.
+		</member>
+		<member name="min_domain" type="float" setter="set_min_domain" getter="get_min_domain" default="0.0">
+			The minimum domain (x-coordinate) that points can have.
 		</member>
 		<member name="min_value" type="float" setter="set_min_value" getter="get_min_value" default="0.0">
-			The minimum value the curve can reach.
+			The minimum value (y-coordinate) that points can have. Tangents can cause lower values between points.
 		</member>
 		<member name="point_count" type="int" setter="set_point_count" getter="get_point_count" default="0">
 			The number of points describing the curve.
 		</member>
 	</members>
 	<signals>
+		<signal name="domain_changed">
+			<description>
+				Emitted when [member max_domain] or [member min_domain] is changed.
+			</description>
+		</signal>
 		<signal name="range_changed">
 			<description>
 				Emitted when [member max_value] or [member min_value] is changed.

+ 2 - 2
doc/classes/CurveTexture.xml

@@ -4,14 +4,14 @@
 		A 1D texture where pixel brightness corresponds to points on a curve.
 	</brief_description>
 	<description>
-		A 1D texture where pixel brightness corresponds to points on a [Curve] resource, either in grayscale or in red. This visual representation simplifies the task of saving curves as image files.
+		A 1D texture where pixel brightness corresponds to points on a unit [Curve] resource, either in grayscale or in red. This visual representation simplifies the task of saving curves as image files.
 		If you need to store up to 3 curves within a single texture, use [CurveXYZTexture] instead. See also [GradientTexture1D] and [GradientTexture2D].
 	</description>
 	<tutorials>
 	</tutorials>
 	<members>
 		<member name="curve" type="Curve" setter="set_curve" getter="get_curve">
-			The [Curve] that is rendered onto the texture.
+			The [Curve] that is rendered onto the texture. Should be a unit [Curve].
 		</member>
 		<member name="resource_local_to_scene" type="bool" setter="set_local_to_scene" getter="is_local_to_scene" overrides="Resource" default="false" />
 		<member name="texture_mode" type="int" setter="set_texture_mode" getter="get_texture_mode" enum="CurveTexture.TextureMode" default="0">

+ 4 - 4
doc/classes/CurveXYZTexture.xml

@@ -4,20 +4,20 @@
 		A 1D texture where the red, green, and blue color channels correspond to points on 3 curves.
 	</brief_description>
 	<description>
-		A 1D texture where the red, green, and blue color channels correspond to points on 3 [Curve] resources. Compared to using separate [CurveTexture]s, this further simplifies the task of saving curves as image files.
+		A 1D texture where the red, green, and blue color channels correspond to points on 3 unit [Curve] resources. Compared to using separate [CurveTexture]s, this further simplifies the task of saving curves as image files.
 		If you only need to store one curve within a single texture, use [CurveTexture] instead. See also [GradientTexture1D] and [GradientTexture2D].
 	</description>
 	<tutorials>
 	</tutorials>
 	<members>
 		<member name="curve_x" type="Curve" setter="set_curve_x" getter="get_curve_x">
-			The [Curve] that is rendered onto the texture's red channel.
+			The [Curve] that is rendered onto the texture's red channel. Should be a unit [Curve].
 		</member>
 		<member name="curve_y" type="Curve" setter="set_curve_y" getter="get_curve_y">
-			The [Curve] that is rendered onto the texture's green channel.
+			The [Curve] that is rendered onto the texture's green channel. Should be a unit [Curve].
 		</member>
 		<member name="curve_z" type="Curve" setter="set_curve_z" getter="get_curve_z">
-			The [Curve] that is rendered onto the texture's blue channel.
+			The [Curve] that is rendered onto the texture's blue channel. Should be a unit [Curve].
 		</member>
 		<member name="resource_local_to_scene" type="bool" setter="set_local_to_scene" getter="is_local_to_scene" overrides="Resource" default="false" />
 		<member name="width" type="int" setter="set_width" getter="get_width" default="256">

+ 1 - 1
doc/classes/Line2D.xml

@@ -101,7 +101,7 @@
 			The polyline's width.
 		</member>
 		<member name="width_curve" type="Curve" setter="set_curve" getter="get_curve">
-			The polyline's width curve. The width of the polyline over its length will be equivalent to the value of the width curve over its domain.
+			The polyline's width curve. The width of the polyline over its length will be equivalent to the value of the width curve over its domain. The width curve should be a unit [Curve].
 		</member>
 	</members>
 	<constants>

+ 1 - 1
doc/classes/RibbonTrailMesh.xml

@@ -13,7 +13,7 @@
 	</tutorials>
 	<members>
 		<member name="curve" type="Curve" setter="set_curve" getter="get_curve">
-			Determines the size of the ribbon along its length. The size of a particular section segment is obtained by multiplying the baseline [member size] by the value of this curve at the given distance. For values smaller than [code]0[/code], the faces will be inverted.
+			Determines the size of the ribbon along its length. The size of a particular section segment is obtained by multiplying the baseline [member size] by the value of this curve at the given distance. For values smaller than [code]0[/code], the faces will be inverted. Should be a unit [Curve].
 		</member>
 		<member name="section_length" type="float" setter="set_section_length" getter="get_section_length" default="0.2">
 			The length of a section of the ribbon.

+ 1 - 1
doc/classes/TubeTrailMesh.xml

@@ -19,7 +19,7 @@
 			If [code]true[/code], generates a cap at the top of the tube. This can be set to [code]false[/code] to speed up generation and rendering when the cap is never seen by the camera.
 		</member>
 		<member name="curve" type="Curve" setter="set_curve" getter="get_curve">
-			Determines the radius of the tube along its length. The radius of a particular section ring is obtained by multiplying the baseline [member radius] by the value of this curve at the given distance. For values smaller than [code]0[/code], the faces will be inverted.
+			Determines the radius of the tube along its length. The radius of a particular section ring is obtained by multiplying the baseline [member radius] by the value of this curve at the given distance. For values smaller than [code]0[/code], the faces will be inverted. Should be a unit [Curve].
 		</member>
 		<member name="radial_steps" type="int" setter="set_radial_steps" getter="get_radial_steps" default="8">
 			The number of sides on the tube. For example, a value of [code]5[/code] means the tube will be pentagonal. Higher values result in a more detailed tube at the cost of performance.

+ 86 - 98
editor/plugins/curve_editor_plugin.cpp

@@ -64,6 +64,7 @@ void CurveEdit::set_curve(Ref<Curve> p_curve) {
 	if (curve.is_valid()) {
 		curve->disconnect_changed(callable_mp(this, &CurveEdit::_curve_changed));
 		curve->disconnect(Curve::SIGNAL_RANGE_CHANGED, callable_mp(this, &CurveEdit::_curve_changed));
+		curve->disconnect(Curve::SIGNAL_DOMAIN_CHANGED, callable_mp(this, &CurveEdit::_curve_changed));
 	}
 
 	curve = p_curve;
@@ -71,6 +72,7 @@ void CurveEdit::set_curve(Ref<Curve> p_curve) {
 	if (curve.is_valid()) {
 		curve->connect_changed(callable_mp(this, &CurveEdit::_curve_changed));
 		curve->connect(Curve::SIGNAL_RANGE_CHANGED, callable_mp(this, &CurveEdit::_curve_changed));
+		curve->connect(Curve::SIGNAL_DOMAIN_CHANGED, callable_mp(this, &CurveEdit::_curve_changed));
 	}
 
 	// Note: if you edit a curve, then set another, and try to undo,
@@ -226,10 +228,10 @@ void CurveEdit::gui_input(const Ref<InputEvent> &p_event) {
 				}
 			} else if (grabbing == GRAB_NONE) {
 				// Adding a new point. Insert a temporary point for the user to adjust, so it's not in the undo/redo.
-				Vector2 new_pos = get_world_pos(mpos).clamp(Vector2(0.0, curve->get_min_value()), Vector2(1.0, curve->get_max_value()));
+				Vector2 new_pos = get_world_pos(mpos).clamp(Vector2(curve->get_min_domain(), curve->get_min_value()), Vector2(curve->get_max_domain(), curve->get_max_value()));
 				if (snap_enabled || mb->is_command_or_control_pressed()) {
-					new_pos.x = Math::snapped(new_pos.x, 1.0 / snap_count);
-					new_pos.y = Math::snapped(new_pos.y - curve->get_min_value(), curve->get_range() / snap_count) + curve->get_min_value();
+					new_pos.x = Math::snapped(new_pos.x - curve->get_min_domain(), curve->get_domain_range() / snap_count) + curve->get_min_domain();
+					new_pos.y = Math::snapped(new_pos.y - curve->get_min_value(), curve->get_value_range() / snap_count) + curve->get_min_value();
 				}
 
 				new_pos.x = get_offset_without_collision(selected_index, new_pos.x, mpos.x >= get_view_pos(new_pos).x);
@@ -276,11 +278,11 @@ void CurveEdit::gui_input(const Ref<InputEvent> &p_event) {
 			if (selected_index != -1) {
 				if (selected_tangent_index == TANGENT_NONE) {
 					// Drag point.
-					Vector2 new_pos = get_world_pos(mpos).clamp(Vector2(0.0, curve->get_min_value()), Vector2(1.0, curve->get_max_value()));
+					Vector2 new_pos = get_world_pos(mpos).clamp(Vector2(curve->get_min_domain(), curve->get_min_value()), Vector2(curve->get_max_domain(), curve->get_max_value()));
 
 					if (snap_enabled || mm->is_command_or_control_pressed()) {
-						new_pos.x = Math::snapped(new_pos.x, 1.0 / snap_count);
-						new_pos.y = Math::snapped(new_pos.y - curve->get_min_value(), curve->get_range() / snap_count) + curve->get_min_value();
+						new_pos.x = Math::snapped(new_pos.x - curve->get_min_domain(), curve->get_domain_range() / snap_count) + curve->get_min_domain();
+						new_pos.y = Math::snapped(new_pos.y - curve->get_min_value(), curve->get_value_range() / snap_count) + curve->get_min_value();
 					}
 
 					// Allow to snap to axes with Shift.
@@ -295,8 +297,8 @@ void CurveEdit::gui_input(const Ref<InputEvent> &p_event) {
 
 					// Allow to constraint the point between the adjacent two with Alt.
 					if (mm->is_alt_pressed()) {
-						float prev_point_offset = (selected_index > 0) ? (curve->get_point_position(selected_index - 1).x + 0.00001) : 0.0;
-						float next_point_offset = (selected_index < curve->get_point_count() - 1) ? (curve->get_point_position(selected_index + 1).x - 0.00001) : 1.0;
+						float prev_point_offset = (selected_index > 0) ? (curve->get_point_position(selected_index - 1).x + 0.00001) : curve->get_min_domain();
+						float next_point_offset = (selected_index < curve->get_point_count() - 1) ? (curve->get_point_position(selected_index + 1).x - 0.00001) : curve->get_max_domain();
 						new_pos.x = CLAMP(new_pos.x, prev_point_offset, next_point_offset);
 					}
 
@@ -357,37 +359,39 @@ void CurveEdit::use_preset(int p_preset_id) {
 	Array previous_data = curve->get_data();
 	curve->clear_points();
 
-	float min_value = curve->get_min_value();
-	float max_value = curve->get_max_value();
+	const float min_y = curve->get_min_value();
+	const float max_y = curve->get_max_value();
+	const float min_x = curve->get_min_domain();
+	const float max_x = curve->get_max_domain();
 
 	switch (p_preset_id) {
 		case PRESET_CONSTANT:
-			curve->add_point(Vector2(0, (min_value + max_value) / 2.0));
-			curve->add_point(Vector2(1, (min_value + max_value) / 2.0));
+			curve->add_point(Vector2(min_x, (min_y + max_y) / 2.0));
+			curve->add_point(Vector2(max_x, (min_y + max_y) / 2.0));
 			curve->set_point_right_mode(0, Curve::TANGENT_LINEAR);
 			curve->set_point_left_mode(1, Curve::TANGENT_LINEAR);
 			break;
 
 		case PRESET_LINEAR:
-			curve->add_point(Vector2(0, min_value));
-			curve->add_point(Vector2(1, max_value));
+			curve->add_point(Vector2(min_x, min_y));
+			curve->add_point(Vector2(max_x, max_y));
 			curve->set_point_right_mode(0, Curve::TANGENT_LINEAR);
 			curve->set_point_left_mode(1, Curve::TANGENT_LINEAR);
 			break;
 
 		case PRESET_EASE_IN:
-			curve->add_point(Vector2(0, min_value));
-			curve->add_point(Vector2(1, max_value), curve->get_range() * 1.4, 0);
+			curve->add_point(Vector2(min_x, min_y));
+			curve->add_point(Vector2(max_x, max_y), curve->get_value_range() / curve->get_domain_range() * 1.4, 0);
 			break;
 
 		case PRESET_EASE_OUT:
-			curve->add_point(Vector2(0, min_value), 0, curve->get_range() * 1.4);
-			curve->add_point(Vector2(1, max_value));
+			curve->add_point(Vector2(min_x, min_y), 0, curve->get_value_range() / curve->get_domain_range() * 1.4);
+			curve->add_point(Vector2(max_x, max_y));
 			break;
 
 		case PRESET_SMOOTHSTEP:
-			curve->add_point(Vector2(0, min_value));
-			curve->add_point(Vector2(1, max_value));
+			curve->add_point(Vector2(min_x, min_y));
+			curve->add_point(Vector2(max_x, max_y));
 			break;
 
 		default:
@@ -411,7 +415,7 @@ void CurveEdit::_curve_changed() {
 	}
 }
 
-int CurveEdit::get_point_at(Vector2 p_pos) const {
+int CurveEdit::get_point_at(const Vector2 &p_pos) const {
 	if (curve.is_null()) {
 		return -1;
 	}
@@ -432,7 +436,7 @@ int CurveEdit::get_point_at(Vector2 p_pos) const {
 	return closest_idx;
 }
 
-CurveEdit::TangentIndex CurveEdit::get_tangent_at(Vector2 p_pos) const {
+CurveEdit::TangentIndex CurveEdit::get_tangent_at(const Vector2 &p_pos) const {
 	if (curve.is_null() || selected_index < 0) {
 		return TANGENT_NONE;
 	}
@@ -491,7 +495,7 @@ float CurveEdit::get_offset_without_collision(int p_current_index, float p_offse
 	return safe_offset;
 }
 
-void CurveEdit::add_point(Vector2 p_pos) {
+void CurveEdit::add_point(const Vector2 &p_pos) {
 	ERR_FAIL_COND(curve.is_null());
 
 	// Add a point to get its index, then remove it immediately. Trick to feed the UndoRedo.
@@ -531,7 +535,7 @@ void CurveEdit::remove_point(int p_index) {
 	undo_redo->commit_action();
 }
 
-void CurveEdit::set_point_position(int p_index, Vector2 p_pos) {
+void CurveEdit::set_point_position(int p_index, const Vector2 &p_pos) {
 	ERR_FAIL_COND(curve.is_null());
 	ERR_FAIL_INDEX_MSG(p_index, curve->get_point_count(), "Curve point is out of bounds.");
 
@@ -657,10 +661,12 @@ void CurveEdit::update_view_transform() {
 
 	const real_t margin = font->get_height(font_size) + 2 * EDSCALE;
 
+	float min_x = curve.is_valid() ? curve->get_min_domain() : 0.0;
+	float max_x = curve.is_valid() ? curve->get_max_domain() : 1.0;
 	float min_y = curve.is_valid() ? curve->get_min_value() : 0.0;
 	float max_y = curve.is_valid() ? curve->get_max_value() : 1.0;
 
-	const Rect2 world_rect = Rect2(Curve::MIN_X, min_y, Curve::MAX_X, max_y - min_y);
+	const Rect2 world_rect = Rect2(min_x, min_y, max_x - min_x, max_y - min_y);
 	const Size2 view_margin(margin, margin);
 	const Size2 view_size = get_size() - view_margin * 2;
 	const Vector2 scale = view_size / world_rect.size;
@@ -707,70 +713,56 @@ Vector2 CurveEdit::get_tangent_view_pos(int p_index, TangentIndex p_tangent) con
 	return tangent_view_pos;
 }
 
-Vector2 CurveEdit::get_view_pos(Vector2 p_world_pos) const {
+Vector2 CurveEdit::get_view_pos(const Vector2 &p_world_pos) const {
 	return _world_to_view.xform(p_world_pos);
 }
 
-Vector2 CurveEdit::get_world_pos(Vector2 p_view_pos) const {
+Vector2 CurveEdit::get_world_pos(const Vector2 &p_view_pos) const {
 	return _world_to_view.affine_inverse().xform(p_view_pos);
 }
 
 // Uses non-baked points, but takes advantage of ordered iteration to be faster.
-template <typename T>
-static void plot_curve_accurate(const Curve &curve, float step, Vector2 scaling, T plot_func) {
-	if (curve.get_point_count() <= 1) {
-		// Not enough points to make a curve, so it's just a straight line.
-		// The added tiny vectors make the drawn line stay exactly within the bounds in practice.
-		float y = curve.sample(0);
-		plot_func(Vector2(0, y) * scaling + Vector2(0.5, 0), Vector2(1.f, y) * scaling - Vector2(1.5, 0), true);
+void CurveEdit::plot_curve_accurate(float p_step, const Color &p_line_color, const Color &p_edge_line_color) {
+	const real_t min_x = curve->get_min_domain();
+	const real_t max_x = curve->get_max_domain();
+	if (curve->get_point_count() <= 1) { // Draw single line through entire plot.
+		real_t y = curve->sample(0);
+		draw_line(get_view_pos(Vector2(min_x, y)) + Vector2(0.5, 0), get_view_pos(Vector2(max_x, y)) - Vector2(1.5, 0), p_line_color, LINE_WIDTH, true);
+		return;
+	}
 
-	} else {
-		Vector2 first_point = curve.get_point_position(0);
-		Vector2 last_point = curve.get_point_position(curve.get_point_count() - 1);
-
-		// Edge lines
-		plot_func(Vector2(0, first_point.y) * scaling + Vector2(0.5, 0), first_point * scaling, false);
-		plot_func(Vector2(Curve::MAX_X, last_point.y) * scaling - Vector2(1.5, 0), last_point * scaling, false);
-
-		// Draw section by section, so that we get maximum precision near points.
-		// It's an accurate representation, but slower than using the baked one.
-		for (int i = 1; i < curve.get_point_count(); ++i) {
-			Vector2 a = curve.get_point_position(i - 1);
-			Vector2 b = curve.get_point_position(i);
-
-			Vector2 pos = a;
-			Vector2 prev_pos = a;
-
-			float scaled_step = step / scaling.x;
-			float samples = (b.x - a.x) / scaled_step;
-
-			for (int j = 1; j < samples; j++) {
-				float x = j * scaled_step;
-				pos.x = a.x + x;
-				pos.y = curve.sample_local_nocheck(i - 1, x);
-				plot_func(prev_pos * scaling, pos * scaling, true);
-				prev_pos = pos;
-			}
+	Vector2 first_point = curve->get_point_position(0);
+	Vector2 last_point = curve->get_point_position(curve->get_point_count() - 1);
 
-			plot_func(prev_pos * scaling, b * scaling, true);
-		}
-	}
-}
+	// Transform pixels-per-step into curve domain. Only works for non-rotated transforms.
+	const float world_step_size = p_step / _world_to_view.get_scale().x;
+
+	// Edge lines.
+	draw_line(get_view_pos(Vector2(min_x, first_point.y)) + Vector2(0.5, 0), get_view_pos(first_point), p_edge_line_color, LINE_WIDTH, true);
+	draw_line(get_view_pos(last_point), get_view_pos(Vector2(max_x, last_point.y)) - Vector2(1.5, 0), p_edge_line_color, LINE_WIDTH, true);
 
-struct CanvasItemPlotCurve {
-	CanvasItem &ci;
-	Color color1;
-	Color color2;
+	// Draw section by section, so that we get maximum precision near points.
+	// It's an accurate representation, but slower than using the baked one.
+	for (int i = 1; i < curve->get_point_count(); ++i) {
+		Vector2 a = curve->get_point_position(i - 1);
+		Vector2 b = curve->get_point_position(i);
 
-	CanvasItemPlotCurve(CanvasItem &p_ci, Color p_color1, Color p_color2) :
-			ci(p_ci),
-			color1(p_color1),
-			color2(p_color2) {}
+		Vector2 pos = a;
+		Vector2 prev_pos = a;
 
-	void operator()(Vector2 pos0, Vector2 pos1, bool in_definition) {
-		ci.draw_line(pos0, pos1, in_definition ? color1 : color2, 0.5, true);
+		float samples = (b.x - a.x) / world_step_size;
+
+		for (int j = 1; j < samples; j++) {
+			float x = j * world_step_size;
+			pos.x = a.x + x;
+			pos.y = curve->sample_local_nocheck(i - 1, x);
+			draw_line(get_view_pos(prev_pos), get_view_pos(pos), p_line_color, LINE_WIDTH, true);
+			prev_pos = pos;
+		}
+
+		draw_line(get_view_pos(prev_pos), get_view_pos(b), p_line_color, LINE_WIDTH, true);
 	}
-};
+}
 
 void CurveEdit::_redraw() {
 	if (curve.is_null()) {
@@ -784,7 +776,7 @@ void CurveEdit::_redraw() {
 	Vector2 view_size = get_rect().size;
 	draw_style_box(get_theme_stylebox(SceneStringName(panel), SNAME("Tree")), Rect2(Point2(), view_size));
 
-	// Draw snapping grid, then primary grid.
+	// Draw primary grid.
 	draw_set_transform_matrix(_world_to_view);
 
 	Vector2 min_edge = get_world_pos(Vector2(0, view_size.y));
@@ -794,15 +786,15 @@ void CurveEdit::_redraw() {
 	const Color grid_color = get_theme_color(SNAME("mono_color"), EditorStringName(Editor)) * Color(1, 1, 1, 0.1);
 
 	const Vector2i grid_steps = Vector2i(4, 2);
-	const Vector2 step_size = Vector2(1, curve->get_range()) / grid_steps;
+	const Vector2 step_size = Vector2(curve->get_domain_range(), curve->get_value_range()) / grid_steps;
 
 	draw_line(Vector2(min_edge.x, curve->get_min_value()), Vector2(max_edge.x, curve->get_min_value()), grid_color_primary);
 	draw_line(Vector2(max_edge.x, curve->get_max_value()), Vector2(min_edge.x, curve->get_max_value()), grid_color_primary);
-	draw_line(Vector2(0, min_edge.y), Vector2(0, max_edge.y), grid_color_primary);
-	draw_line(Vector2(1, max_edge.y), Vector2(1, min_edge.y), grid_color_primary);
+	draw_line(Vector2(curve->get_min_domain(), min_edge.y), Vector2(curve->get_min_domain(), max_edge.y), grid_color_primary);
+	draw_line(Vector2(curve->get_max_domain(), max_edge.y), Vector2(curve->get_max_domain(), min_edge.y), grid_color_primary);
 
 	for (int i = 1; i < grid_steps.x; i++) {
-		real_t x = i * step_size.x;
+		real_t x = curve->get_min_domain() + i * step_size.x;
 		draw_line(Vector2(x, min_edge.y), Vector2(x, max_edge.y), grid_color);
 	}
 
@@ -819,30 +811,26 @@ void CurveEdit::_redraw() {
 	float font_height = font->get_height(font_size);
 	Color text_color = get_theme_color(SceneStringName(font_color), EditorStringName(Editor));
 
+	int pad = Math::round(2 * EDSCALE);
+
 	for (int i = 0; i <= grid_steps.x; ++i) {
-		real_t x = i * step_size.x;
-		draw_string(font, get_view_pos(Vector2(x - step_size.x / 2, curve->get_min_value())) + Vector2(0, font_height - Math::round(2 * EDSCALE)), String::num(x, 2), HORIZONTAL_ALIGNMENT_CENTER, get_view_pos(Vector2(step_size.x, 0)).x, font_size, text_color);
+		real_t x = curve->get_min_domain() + i * step_size.x;
+		draw_string(font, get_view_pos(Vector2(x, curve->get_min_value())) + Vector2(pad, font_height - pad), String::num(x, 2), HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, text_color);
 	}
 
 	for (int i = 0; i <= grid_steps.y; ++i) {
 		real_t y = curve->get_min_value() + i * step_size.y;
-		draw_string(font, get_view_pos(Vector2(0, y)) + Vector2(2, -2), String::num(y, 2), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, text_color);
+		draw_string(font, get_view_pos(Vector2(curve->get_min_domain(), y)) + Vector2(pad, -pad), String::num(y, 2), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, text_color);
 	}
 
-	// Draw curve.
-
-	// An unusual transform so we can offset the curve before scaling it up, allowing the curve to be antialiased.
-	// The scaling up ensures that the curve rendering doesn't break when we use a quad line to draw it.
-	draw_set_transform_matrix(Transform2D(0, get_view_pos(Vector2(0, 0))));
+	// Draw curve in view coordinates. Curve world-to-view point conversion happens in plot_curve_accurate().
 
 	const Color line_color = get_theme_color(SceneStringName(font_color), EditorStringName(Editor));
 	const Color edge_line_color = get_theme_color(SceneStringName(font_color), EditorStringName(Editor)) * Color(1, 1, 1, 0.75);
 
-	CanvasItemPlotCurve plot_func(*this, line_color, edge_line_color);
-	plot_curve_accurate(**curve, 2.f, (get_view_pos(Vector2(1, curve->get_max_value())) - get_view_pos(Vector2(0, curve->get_min_value()))) / Vector2(1, curve->get_range()), plot_func);
+	plot_curve_accurate(STEP_SIZE, line_color, edge_line_color);
 
 	// Draw points, except for the selected one.
-	draw_set_transform_matrix(Transform2D());
 
 	bool shift_pressed = Input::get_singleton()->is_key_pressed(Key::SHIFT);
 
@@ -934,8 +922,8 @@ void CurveEdit::_redraw() {
 	draw_set_transform_matrix(_world_to_view);
 
 	if (Input::get_singleton()->is_key_pressed(Key::ALT) && grabbing != GRAB_NONE && selected_tangent_index == TANGENT_NONE) {
-		float prev_point_offset = (selected_index > 0) ? curve->get_point_position(selected_index - 1).x : 0.0;
-		float next_point_offset = (selected_index < curve->get_point_count() - 1) ? curve->get_point_position(selected_index + 1).x : 1.0;
+		float prev_point_offset = (selected_index > 0) ? curve->get_point_position(selected_index - 1).x : curve->get_min_domain();
+		float next_point_offset = (selected_index < curve->get_point_count() - 1) ? curve->get_point_position(selected_index + 1).x : curve->get_max_domain();
 
 		draw_line(Vector2(prev_point_offset, curve->get_min_value()), Vector2(prev_point_offset, curve->get_max_value()), Color(point_color, 0.6));
 		draw_line(Vector2(next_point_offset, curve->get_min_value()), Vector2(next_point_offset, curve->get_max_value()), Color(point_color, 0.6));
@@ -943,7 +931,7 @@ void CurveEdit::_redraw() {
 
 	if (shift_pressed && grabbing != GRAB_NONE && selected_tangent_index == TANGENT_NONE) {
 		draw_line(Vector2(initial_grab_pos.x, curve->get_min_value()), Vector2(initial_grab_pos.x, curve->get_max_value()), get_theme_color(SNAME("axis_x_color"), EditorStringName(Editor)).darkened(0.4));
-		draw_line(Vector2(0, initial_grab_pos.y), Vector2(1, initial_grab_pos.y), get_theme_color(SNAME("axis_y_color"), EditorStringName(Editor)).darkened(0.4));
+		draw_line(Vector2(curve->get_min_domain(), initial_grab_pos.y), Vector2(curve->get_max_domain(), initial_grab_pos.y), get_theme_color(SNAME("axis_y_color"), EditorStringName(Editor)).darkened(0.4));
 	}
 }
 
@@ -1079,15 +1067,15 @@ Ref<Texture2D> CurvePreviewGenerator::generate(const Ref<Resource> &p_from, cons
 	Color line_color = EditorInterface::get_singleton()->get_editor_theme()->get_color(SceneStringName(font_color), EditorStringName(Editor));
 
 	// Set the first pixel of the thumbnail.
-	float v = (curve->sample_baked(0) - curve->get_min_value()) / curve->get_range();
+	float v = (curve->sample_baked(curve->get_min_domain()) - curve->get_min_value()) / curve->get_value_range();
 	int y = CLAMP(im.get_height() - v * im.get_height(), 0, im.get_height() - 1);
 	im.set_pixel(0, y, line_color);
 
 	// Plot a line towards the next point.
 	int prev_y = y;
 	for (int x = 1; x < im.get_width(); ++x) {
-		float t = static_cast<float>(x) / im.get_width();
-		v = (curve->sample_baked(t) - curve->get_min_value()) / curve->get_range();
+		float t = static_cast<float>(x) / im.get_width() * curve->get_domain_range() + curve->get_min_domain();
+		v = (curve->sample_baked(t) - curve->get_min_value()) / curve->get_value_range();
 		y = CLAMP(im.get_height() - v * im.get_height(), 0, im.get_height() - 1);
 
 		Vector<Point2i> points = Geometry2D::bresenham_line(Point2i(x - 1, prev_y), Point2i(x, y));

+ 13 - 10
editor/plugins/curve_editor_plugin.h

@@ -78,14 +78,14 @@ private:
 	virtual void gui_input(const Ref<InputEvent> &p_event) override;
 	void _curve_changed();
 
-	int get_point_at(Vector2 p_pos) const;
-	TangentIndex get_tangent_at(Vector2 p_pos) const;
+	int get_point_at(const Vector2 &p_pos) const;
+	TangentIndex get_tangent_at(const Vector2 &p_pos) const;
 
 	float get_offset_without_collision(int p_current_index, float p_offset, bool p_prioritize_right = true);
 
-	void add_point(Vector2 p_pos);
+	void add_point(const Vector2 &p_pos);
 	void remove_point(int p_index);
-	void set_point_position(int p_index, Vector2 p_pos);
+	void set_point_position(int p_index, const Vector2 &p_pos);
 
 	void set_point_tangents(int p_index, float p_left, float p_right);
 	void set_point_left_tangent(int p_index, float p_tangent);
@@ -94,17 +94,20 @@ private:
 
 	void update_view_transform();
 
+	void plot_curve_accurate(float p_step, const Color &p_line_color, const Color &p_edge_line_color);
+
 	void set_selected_index(int p_index);
-	void set_selected_tangent_index(TangentIndex p_tangent);
 
 	Vector2 get_tangent_view_pos(int p_index, TangentIndex p_tangent) const;
-	Vector2 get_view_pos(Vector2 p_world_pos) const;
-	Vector2 get_world_pos(Vector2 p_view_pos) const;
+	Vector2 get_view_pos(const Vector2 &p_world_pos) const;
+	Vector2 get_world_pos(const Vector2 &p_view_pos) const;
 
 	void _redraw();
 
 private:
 	const float ASPECT_RATIO = 6.f / 13.f;
+	const float LINE_WIDTH = 0.5f;
+	const int STEP_SIZE = 2; // Number of pixels between plot points.
 
 	Transform2D _world_to_view;
 
@@ -136,9 +139,9 @@ private:
 	};
 	GrabMode grabbing = GRAB_NONE;
 	Vector2 initial_grab_pos;
-	int initial_grab_index;
-	float initial_grab_left_tangent;
-	float initial_grab_right_tangent;
+	int initial_grab_index = -1;
+	float initial_grab_left_tangent = 0;
+	float initial_grab_right_tangent = 0;
 
 	bool snap_enabled = false;
 	int snap_count = 10;

+ 97 - 39
scene/resources/curve.cpp

@@ -33,6 +33,7 @@
 #include "core/math/math_funcs.h"
 
 const char *Curve::SIGNAL_RANGE_CHANGED = "range_changed";
+const char *Curve::SIGNAL_DOMAIN_CHANGED = "domain_changed";
 
 Curve::Curve() {
 }
@@ -56,14 +57,11 @@ void Curve::set_point_count(int p_count) {
 }
 
 int Curve::_add_point(Vector2 p_position, real_t p_left_tangent, real_t p_right_tangent, TangentMode p_left_mode, TangentMode p_right_mode) {
-	// Add a point and preserve order
+	// Add a point and preserve order.
 
-	// Curve bounds is in 0..1
-	if (p_position.x > MAX_X) {
-		p_position.x = MAX_X;
-	} else if (p_position.x < MIN_X) {
-		p_position.x = MIN_X;
-	}
+	// Points must remain within the given value and domain ranges.
+	p_position.x = CLAMP(p_position.x, _min_domain, _max_domain);
+	p_position.y = CLAMP(p_position.y, _min_value, _max_value);
 
 	int ret = -1;
 
@@ -88,11 +86,11 @@ int Curve::_add_point(Vector2 p_position, real_t p_left_tangent, real_t p_right_
 		int i = get_index(p_position.x);
 
 		if (i == 0 && p_position.x < _points[0].position.x) {
-			// Insert before anything else
+			// Insert before anything else.
 			_points.insert(0, Point(p_position, p_left_tangent, p_right_tangent, p_left_mode, p_right_mode));
 			ret = 0;
 		} else {
-			// Insert between i and i+1
+			// Insert between i and i+1.
 			++i;
 			_points.insert(i, Point(p_position, p_left_tangent, p_right_tangent, p_left_mode, p_right_mode));
 			ret = i;
@@ -121,7 +119,7 @@ int Curve::add_point_no_update(Vector2 p_position, real_t p_left_tangent, real_t
 }
 
 int Curve::get_index(real_t p_offset) const {
-	// Lower-bound float binary search
+	// Lower-bound float binary search.
 
 	int imin = 0;
 	int imax = _points.size() - 1;
@@ -134,16 +132,14 @@ int Curve::get_index(real_t p_offset) const {
 
 		if (a < p_offset && b < p_offset) {
 			imin = m;
-
 		} else if (a > p_offset) {
 			imax = m;
-
 		} else {
 			return m;
 		}
 	}
 
-	// Will happen if the offset is out of bounds
+	// Will happen if the offset is out of bounds.
 	if (p_offset > _points[imax].position.x) {
 		return imax;
 	}
@@ -305,30 +301,80 @@ void Curve::update_auto_tangents(int p_index) {
 	}
 }
 
+#define MIN_X_RANGE 0.01
 #define MIN_Y_RANGE 0.01
 
+Array Curve::get_limits() const {
+	Array output;
+	output.resize(4);
+
+	output[0] = _min_value;
+	output[1] = _max_value;
+	output[2] = _min_domain;
+	output[3] = _max_domain;
+
+	return output;
+}
+
+void Curve::set_limits(const Array &p_input) {
+	if (p_input.size() != 4) {
+		WARN_PRINT_ED(vformat(R"(Could not find Curve limit values when deserializing "%s". Resetting limits to default values.)", this->get_path()));
+		_min_value = 0;
+		_max_value = 1;
+		_min_domain = 0;
+		_max_domain = 1;
+		return;
+	}
+
+	// Do not use setters because we don't want to enforce their logical constraints during deserialization.
+	_min_value = p_input[0];
+	_max_value = p_input[1];
+	_min_domain = p_input[2];
+	_max_domain = p_input[3];
+}
+
 void Curve::set_min_value(real_t p_min) {
-	if (_minmax_set_once & 0b11 && p_min > _max_value - MIN_Y_RANGE) {
-		_min_value = _max_value - MIN_Y_RANGE;
-	} else {
-		_minmax_set_once |= 0b10; // first bit is "min set"
-		_min_value = p_min;
+	_min_value = MIN(p_min, _max_value - MIN_Y_RANGE);
+
+	for (const Point &p : _points) {
+		_min_value = MIN(_min_value, p.position.y);
 	}
-	// Note: min and max are indicative values,
-	// it's still possible that existing points are out of range at this point.
+
 	emit_signal(SNAME(SIGNAL_RANGE_CHANGED));
 }
 
 void Curve::set_max_value(real_t p_max) {
-	if (_minmax_set_once & 0b11 && p_max < _min_value + MIN_Y_RANGE) {
-		_max_value = _min_value + MIN_Y_RANGE;
-	} else {
-		_minmax_set_once |= 0b01; // second bit is "max set"
-		_max_value = p_max;
+	_max_value = MAX(p_max, _min_value + MIN_Y_RANGE);
+
+	for (const Point &p : _points) {
+		_max_value = MAX(_max_value, p.position.y);
 	}
+
 	emit_signal(SNAME(SIGNAL_RANGE_CHANGED));
 }
 
+void Curve::set_min_domain(real_t p_min) {
+	_min_domain = MIN(p_min, _max_domain - MIN_X_RANGE);
+
+	if (_points.size() > 0 && _min_domain > _points[0].position.x) {
+		_min_domain = _points[0].position.x;
+	}
+
+	mark_dirty();
+	emit_signal(SNAME(SIGNAL_DOMAIN_CHANGED));
+}
+
+void Curve::set_max_domain(real_t p_max) {
+	_max_domain = MAX(p_max, _min_domain + MIN_X_RANGE);
+
+	if (_points.size() > 0 && _max_domain < _points[_points.size() - 1].position.x) {
+		_max_domain = _points[_points.size() - 1].position.x;
+	}
+
+	mark_dirty();
+	emit_signal(SNAME(SIGNAL_DOMAIN_CHANGED));
+}
+
 real_t Curve::sample(real_t p_offset) const {
 	if (_points.size() == 0) {
 		return 0;
@@ -370,7 +416,7 @@ real_t Curve::sample_local_nocheck(int p_index, real_t p_local_offset) const {
 	 * d1 == d2 == d3 == d / 3
 	 */
 
-	// Control points are chosen at equal distances
+	// Control points are chosen at equal distances.
 	real_t d = b.position.x - a.position.x;
 	if (Math::is_zero_approx(d)) {
 		return b.position.y;
@@ -458,7 +504,7 @@ void Curve::bake() {
 	_baked_cache.resize(_bake_resolution);
 
 	for (int i = 1; i < _bake_resolution - 1; ++i) {
-		real_t x = i / static_cast<real_t>(_bake_resolution - 1);
+		real_t x = get_domain_range() * i / static_cast<real_t>(_bake_resolution - 1) + _min_domain;
 		real_t y = sample(x);
 		_baked_cache.write[i] = y;
 	}
@@ -480,11 +526,11 @@ void Curve::set_bake_resolution(int p_resolution) {
 
 real_t Curve::sample_baked(real_t p_offset) const {
 	if (_baked_cache_dirty) {
-		// Last-second bake if not done already
+		// Last-second bake if not done already.
 		const_cast<Curve *>(this)->bake();
 	}
 
-	// Special cases if the cache is too small
+	// Special cases if the cache is too small.
 	if (_baked_cache.size() == 0) {
 		if (_points.size() == 0) {
 			return 0;
@@ -494,8 +540,8 @@ real_t Curve::sample_baked(real_t p_offset) const {
 		return _baked_cache[0];
 	}
 
-	// Get interpolation index
-	real_t fi = p_offset * (_baked_cache.size() - 1);
+	// Get interpolation index.
+	real_t fi = (p_offset - _min_domain) / get_domain_range() * (_baked_cache.size() - 1);
 	int i = Math::floor(fi);
 	if (i < 0) {
 		i = 0;
@@ -505,7 +551,7 @@ real_t Curve::sample_baked(real_t p_offset) const {
 		fi = 0;
 	}
 
-	// Sample
+	// Sample.
 	if (i + 1 < _baked_cache.size()) {
 		real_t t = fi - i;
 		return Math::lerp(_baked_cache[i], _baked_cache[i + 1], t);
@@ -628,6 +674,14 @@ void Curve::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("set_min_value", "min"), &Curve::set_min_value);
 	ClassDB::bind_method(D_METHOD("get_max_value"), &Curve::get_max_value);
 	ClassDB::bind_method(D_METHOD("set_max_value", "max"), &Curve::set_max_value);
+	ClassDB::bind_method(D_METHOD("get_value_range"), &Curve::get_value_range);
+	ClassDB::bind_method(D_METHOD("get_min_domain"), &Curve::get_min_domain);
+	ClassDB::bind_method(D_METHOD("set_min_domain", "min"), &Curve::set_min_domain);
+	ClassDB::bind_method(D_METHOD("get_max_domain"), &Curve::get_max_domain);
+	ClassDB::bind_method(D_METHOD("set_max_domain", "max"), &Curve::set_max_domain);
+	ClassDB::bind_method(D_METHOD("get_domain_range"), &Curve::get_domain_range);
+	ClassDB::bind_method(D_METHOD("_get_limits"), &Curve::get_limits);
+	ClassDB::bind_method(D_METHOD("_set_limits", "data"), &Curve::set_limits);
 	ClassDB::bind_method(D_METHOD("clean_dupes"), &Curve::clean_dupes);
 	ClassDB::bind_method(D_METHOD("bake"), &Curve::bake);
 	ClassDB::bind_method(D_METHOD("get_bake_resolution"), &Curve::get_bake_resolution);
@@ -635,13 +689,17 @@ void Curve::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("_get_data"), &Curve::get_data);
 	ClassDB::bind_method(D_METHOD("_set_data", "data"), &Curve::set_data);
 
-	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "min_value", PROPERTY_HINT_RANGE, "-1024,1024,0.01"), "set_min_value", "get_min_value");
-	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "max_value", PROPERTY_HINT_RANGE, "-1024,1024,0.01"), "set_max_value", "get_max_value");
+	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "min_domain", PROPERTY_HINT_RANGE, "-1024,1024,0.01,or_greater,or_less", PROPERTY_USAGE_EDITOR), "set_min_domain", "get_min_domain");
+	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "max_domain", PROPERTY_HINT_RANGE, "-1024,1024,0.01,or_greater,or_less", PROPERTY_USAGE_EDITOR), "set_max_domain", "get_max_domain");
+	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "min_value", PROPERTY_HINT_RANGE, "-1024,1024,0.01,or_greater,or_less", PROPERTY_USAGE_EDITOR), "set_min_value", "get_min_value");
+	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "max_value", PROPERTY_HINT_RANGE, "-1024,1024,0.01,or_greater,or_less", PROPERTY_USAGE_EDITOR), "set_max_value", "get_max_value");
+	ADD_PROPERTY(PropertyInfo(Variant::NIL, "_limits", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL), "_set_limits", "_get_limits");
 	ADD_PROPERTY(PropertyInfo(Variant::INT, "bake_resolution", PROPERTY_HINT_RANGE, "1,1000,1"), "set_bake_resolution", "get_bake_resolution");
 	ADD_PROPERTY(PropertyInfo(Variant::INT, "_data", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL), "_set_data", "_get_data");
 	ADD_ARRAY_COUNT("Points", "point_count", "set_point_count", "get_point_count", "point_");
 
 	ADD_SIGNAL(MethodInfo(SIGNAL_RANGE_CHANGED));
+	ADD_SIGNAL(MethodInfo(SIGNAL_DOMAIN_CHANGED));
 
 	BIND_ENUM_CONSTANT(TANGENT_FREE);
 	BIND_ENUM_CONSTANT(TANGENT_LINEAR);
@@ -852,7 +910,7 @@ void Curve2D::_bake() const {
 		return;
 	}
 
-	// Tessellate curve to (almost) even length segments
+	// Tessellate curve to (almost) even length segments.
 	{
 		Vector<RBMap<real_t, Vector2>> midpoints = _tessellate_even_length(10, bake_interval);
 
@@ -1592,7 +1650,7 @@ void Curve3D::_bake() const {
 		return;
 	}
 
-	// Step 1: Tessellate curve to (almost) even length segments
+	// Step 1: Tessellate curve to (almost) even length segments.
 	{
 		Vector<RBMap<real_t, Vector3>> midpoints = _tessellate_even_length(10, bake_interval);
 
@@ -1653,7 +1711,7 @@ void Curve3D::_bake() const {
 		return;
 	}
 
-	// Step 2: Calculate the up vectors and the whole local reference frame
+	// Step 2: Calculate the up vectors and the whole local reference frame.
 	//
 	// See Dougan, Carl. "The parallel transport frame." Game Programming Gems 2 (2001): 215-219.
 	// for an example discussing about why not the Frenet frame.
@@ -1689,7 +1747,7 @@ void Curve3D::_bake() const {
 			Basis rotate;
 			rotate.rotate_to_align(-frame_prev.get_column(2), forward);
 			frame = rotate * frame_prev;
-			frame.orthonormalize(); // guard against float error accumulation
+			frame.orthonormalize(); // Guard against float error accumulation.
 
 			up_write[idx] = frame.get_column(1);
 			frame_prev = frame;
@@ -1941,7 +1999,7 @@ real_t Curve3D::sample_baked_tilt(real_t p_offset) const {
 		return baked_tilt_cache.get(0);
 	}
 
-	p_offset = CLAMP(p_offset, 0.0, get_baked_length()); // PathFollower implement wrapping logic
+	p_offset = CLAMP(p_offset, 0.0, get_baked_length()); // PathFollower implement wrapping logic.
 
 	Curve3D::Interval interval = _find_interval(p_offset);
 	return _sample_baked_tilt(interval);

+ 12 - 6
scene/resources/curve.h

@@ -38,10 +38,8 @@ class Curve : public Resource {
 	GDCLASS(Curve, Resource);
 
 public:
-	static const int MIN_X = 0.f;
-	static const int MAX_X = 1.f;
-
 	static const char *SIGNAL_RANGE_CHANGED;
+	static const char *SIGNAL_DOMAIN_CHANGED;
 
 	enum TangentMode {
 		TANGENT_FREE = 0,
@@ -101,11 +99,18 @@ public:
 
 	real_t get_min_value() const { return _min_value; }
 	void set_min_value(real_t p_min);
-
 	real_t get_max_value() const { return _max_value; }
 	void set_max_value(real_t p_max);
+	real_t get_value_range() const { return _max_value - _min_value; }
+
+	real_t get_min_domain() const { return _min_domain; }
+	void set_min_domain(real_t p_min);
+	real_t get_max_domain() const { return _max_domain; }
+	void set_max_domain(real_t p_max);
+	real_t get_domain_range() const { return _max_domain - _min_domain; }
 
-	real_t get_range() const { return _max_value - _min_value; }
+	Array get_limits() const;
+	void set_limits(const Array &p_input);
 
 	real_t sample(real_t p_offset) const;
 	real_t sample_local_nocheck(int p_index, real_t p_local_offset) const;
@@ -156,7 +161,8 @@ private:
 	int _bake_resolution = 100;
 	real_t _min_value = 0.0;
 	real_t _max_value = 1.0;
-	int _minmax_set_once = 0b00; // Encodes whether min and max have been set a first time, first bit for min and second for max.
+	real_t _min_domain = 0.0;
+	real_t _max_domain = 1.0;
 };
 
 VARIANT_ENUM_CAST(Curve::TangentMode)

+ 170 - 2
tests/scene/test_curve.h

@@ -55,7 +55,7 @@ TEST_CASE("[Curve] Default curve") {
 			"Default curve should return the expected value at offset 1.0.");
 }
 
-TEST_CASE("[Curve] Custom curve with free tangents") {
+TEST_CASE("[Curve] Custom unit curve with free tangents") {
 	Ref<Curve> curve = memnew(Curve);
 	// "Sawtooth" curve with an open ending towards the 1.0 offset.
 	curve->add_point(Vector2(0, 0));
@@ -136,7 +136,90 @@ TEST_CASE("[Curve] Custom curve with free tangents") {
 			"Custom free curve should return the expected baked value at offset 0.6 after clearing all points.");
 }
 
-TEST_CASE("[Curve] Custom curve with linear tangents") {
+TEST_CASE("[Curve] Custom non-unit curve with free tangents") {
+	Ref<Curve> curve = memnew(Curve);
+	curve->set_min_domain(-100.0);
+	curve->set_max_domain(100.0);
+	// "Sawtooth" curve with an open ending towards the 100 offset.
+	curve->add_point(Vector2(-100, 0));
+	curve->add_point(Vector2(-50, 1));
+	curve->add_point(Vector2(0, 0));
+	curve->add_point(Vector2(50, 1));
+	curve->set_bake_resolution(11);
+
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->get_point_left_tangent(0)),
+			"get_point_left_tangent() should return the expected value for point index 0.");
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->get_point_right_tangent(0)),
+			"get_point_right_tangent() should return the expected value for point index 0.");
+	CHECK_MESSAGE(
+			curve->get_point_left_mode(0) == Curve::TangentMode::TANGENT_FREE,
+			"get_point_left_mode() should return the expected value for point index 0.");
+	CHECK_MESSAGE(
+			curve->get_point_right_mode(0) == Curve::TangentMode::TANGENT_FREE,
+			"get_point_right_mode() should return the expected value for point index 0.");
+
+	CHECK_MESSAGE(
+			curve->get_point_count() == 4,
+			"Custom free curve should contain the expected number of points.");
+
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->sample(-200)),
+			"Custom free curve should return the expected value at offset -200.");
+	CHECK_MESSAGE(
+			curve->sample(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.352),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 0.1.");
+	CHECK_MESSAGE(
+			curve->sample(0.4 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.352),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 0.4.");
+	CHECK_MESSAGE(
+			curve->sample(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.896),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 0.7.");
+	CHECK_MESSAGE(
+			curve->sample(1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 1.");
+	CHECK_MESSAGE(
+			curve->sample(2 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 2.");
+
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->sample_baked(-200)),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's -0.1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.352),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 0.1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.4 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.352),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 0.4.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.896),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 0.7.");
+	CHECK_MESSAGE(
+			curve->sample_baked(1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(2 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 2.");
+
+	curve->remove_point(1);
+	CHECK_MESSAGE(
+			curve->sample(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(0),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 0.1 after removing point at index 1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(0),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 0.1 after removing point at index 1.");
+
+	curve->clear_points();
+	CHECK_MESSAGE(
+			curve->sample(0.6 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(0),
+			"Custom free curve should return the expected value at offset 0.6 after clearing all points.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.6 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(0),
+			"Custom free curve should return the expected baked value at offset 0.6 after clearing all points.");
+}
+
+TEST_CASE("[Curve] Custom unit curve with linear tangents") {
 	Ref<Curve> curve = memnew(Curve);
 	// "Sawtooth" curve with an open ending towards the 1.0 offset.
 	curve->add_point(Vector2(0, 0), 0, 0, Curve::TangentMode::TANGENT_LINEAR, Curve::TangentMode::TANGENT_LINEAR);
@@ -219,6 +302,91 @@ TEST_CASE("[Curve] Custom curve with linear tangents") {
 			"Custom free curve should return the expected baked value at offset 0.7 after removing point at invalid index 10.");
 }
 
+TEST_CASE("[Curve] Custom non-unit curve with linear tangents") {
+	Ref<Curve> curve = memnew(Curve);
+	curve->set_min_domain(-100.0);
+	curve->set_max_domain(100.0);
+	// "Sawtooth" curve with an open ending towards the 100 offset.
+	curve->add_point(Vector2(-100, 0), 0, 0, Curve::TangentMode::TANGENT_LINEAR, Curve::TangentMode::TANGENT_LINEAR);
+	curve->add_point(Vector2(-50, 1), 0, 0, Curve::TangentMode::TANGENT_LINEAR, Curve::TangentMode::TANGENT_LINEAR);
+	curve->add_point(Vector2(0, 0), 0, 0, Curve::TangentMode::TANGENT_LINEAR, Curve::TangentMode::TANGENT_LINEAR);
+	curve->add_point(Vector2(50, 1), 0, 0, Curve::TangentMode::TANGENT_LINEAR, Curve::TangentMode::TANGENT_LINEAR);
+
+	CHECK_MESSAGE(
+			curve->get_point_left_tangent(3) == doctest::Approx(1.f / 50),
+			"get_point_left_tangent() should return the expected value for point index 3.");
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->get_point_right_tangent(3)),
+			"get_point_right_tangent() should return the expected value for point index 3.");
+	CHECK_MESSAGE(
+			curve->get_point_left_mode(3) == Curve::TangentMode::TANGENT_LINEAR,
+			"get_point_left_mode() should return the expected value for point index 3.");
+	CHECK_MESSAGE(
+			curve->get_point_right_mode(3) == Curve::TangentMode::TANGENT_LINEAR,
+			"get_point_right_mode() should return the expected value for point index 3.");
+
+	ERR_PRINT_OFF;
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->get_point_right_tangent(300)),
+			"get_point_right_tangent() should return the expected value for invalid point index 300.");
+	CHECK_MESSAGE(
+			curve->get_point_left_mode(-12345) == Curve::TangentMode::TANGENT_FREE,
+			"get_point_left_mode() should return the expected value for invalid point index -12345.");
+	ERR_PRINT_ON;
+
+	CHECK_MESSAGE(
+			curve->get_point_count() == 4,
+			"Custom linear unit curve should contain the expected number of points.");
+
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->sample(-0.1 * curve->get_domain_range() + curve->get_min_domain())),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's -0.1.");
+	CHECK_MESSAGE(
+			curve->sample(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.4),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's 0.1.");
+	CHECK_MESSAGE(
+			curve->sample(0.4 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.4),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's 0.4.");
+	CHECK_MESSAGE(
+			curve->sample(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.8),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's 0.7.");
+	CHECK_MESSAGE(
+			curve->sample(1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's 1.0.");
+	CHECK_MESSAGE(
+			curve->sample(2 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom linear curve should return the expected value at offset equivalent to a unit curve's 2.0.");
+
+	CHECK_MESSAGE(
+			Math::is_zero_approx(curve->sample_baked(-0.1 * curve->get_domain_range() + curve->get_min_domain())),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's -0.1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.4),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's 0.1.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.4 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.4),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's 0.4.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.8),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's 0.7.");
+	CHECK_MESSAGE(
+			curve->sample_baked(1 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's 1.0.");
+	CHECK_MESSAGE(
+			curve->sample_baked(2 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx(1),
+			"Custom linear curve should return the expected baked value at offset equivalent to a unit curve's 2.0.");
+
+	ERR_PRINT_OFF;
+	curve->remove_point(10);
+	ERR_PRINT_ON;
+	CHECK_MESSAGE(
+			curve->sample(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.8),
+			"Custom free curve should return the expected value at offset equivalent to a unit curve's 0.7 after removing point at invalid index 10.");
+	CHECK_MESSAGE(
+			curve->sample_baked(0.7 * curve->get_domain_range() + curve->get_min_domain()) == doctest::Approx((real_t)0.8),
+			"Custom free curve should return the expected baked value at offset equivalent to a unit curve's 0.7 after removing point at invalid index 10.");
+}
+
 TEST_CASE("[Curve] Straight line offset test") {
 	Ref<Curve> curve = memnew(Curve);
 	curve->add_point(Vector2(0, 0));