OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / nodes / curve_nodes.cpp

629 lines · cpp ref:
#include "curve_nodes.h"
#include "../graph/graph_executor.h"

#include <cmath>

namespace godot {

// --- CurveCircleNode ---

void CurveCircleNode::_bind_methods() {
}

CurveCircleNode::CurveCircleNode() {
	// BLENDER'S ORDER IS REVERSED FROM OURS. Resolution is socket 0 and Radius
	// is socket 4, with three Point sockets between them for the
	// circle-through-three-points mode. Ours had Radius at 0 and Resolution at
	// 1, so a graph wiring a resolution set the RADIUS and vice versa -- both
	// links landed, on each other's socket.
	input_defs.push_back({ "Resolution", SocketType::Int, 32 });
	input_defs.push_back({ "Point 1", SocketType::Vector, Vector3(-1, 0, 0) });
	input_defs.push_back({ "Point 2", SocketType::Vector, Vector3(0, 1, 0) });
	input_defs.push_back({ "Point 3", SocketType::Vector, Vector3(1, 0, 0) });
	input_defs.push_back({ "Radius", SocketType::Float, 1.0f });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	// Only meaningful in POINTS mode, where the circle is fitted to the three
	// points; it is the fitted circle's centre.
	output_defs.push_back({ "Center", SocketType::Vector, Variant() });
}

void CurveCircleNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// Blender's indices: Resolution 0, Radius 4.
	int resolution = CLAMP(p_ctx.read_int(p_node_idx, 0), 3, 1024);
	float radius = p_ctx.read_float(p_node_idx, 4);
	if (radius <= 0.0f) {
		radius = 1.0f;
	}

	Ref<ProcCurves> curves;
	curves.instantiate();

	// THE PLANE IS A FRAME, exactly as it is for the mesh primitives: Blender's
	// circle lies in XY with Z up, ours in XZ with Y up. A Blender-framed graph
	// gets Blender's plane so the arithmetic below sees Blender's numbers.
	//
	// AND BLENDER'S CIRCLE IS CYCLIC, with `resolution` points and no repeat.
	// Ours emitted resolution + 1, duplicating the first -- one more point than
	// Blender for the same resolution, and a seam where the duplicate sat.
	const bool blender_frame = (p_ctx.graph != nullptr && p_ctx.graph->is_blender_frame());
	const int point_count = blender_frame ? resolution : resolution + 1;
	for (int i = 0; i < point_count; i++) {
		float angle = static_cast<float>(i) / resolution * Math_TAU;
		curves->positions.push_back(blender_frame
						? Vector3(cosf(angle) * radius, sinf(angle) * radius, 0.0f)
						: Vector3(cosf(angle) * radius, 0.0f, sinf(angle) * radius));
		curves->radii.push_back(0.01f);
	}

	curves->curve_offsets.push_back(0);
	curves->curve_offsets.push_back(point_count);
	// Blender's circle is CYCLIC; in the Godot frame it stays the open,
	// first-point-repeated form it has always been.
	curves->curve_cyclic.push_back(blender_frame ? 1 : 0);
	curves->curve_types.push_back(static_cast<int32_t>(CurveType::Poly));

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(curves);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- CurveLineNode ---

void CurveLineNode::_bind_methods() {
}

CurveLineNode::CurveLineNode() {
	input_defs.push_back({ "Start", SocketType::Vector, Vector3(0, 0, 0) });
	input_defs.push_back({ "End", SocketType::Vector, Vector3(0, 0, 1) });
	// DIRECTION mode's pair. Blender keeps all four sockets on the node and
	// reads the pair its mode selects.
	input_defs.push_back({ "Direction", SocketType::Vector, Vector3(0, 0, 1) });
	input_defs.push_back({ "Length", SocketType::Float, 1.0f });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
}

void CurveLineNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Vector3 start = p_ctx.read_vector(p_node_idx, 0);
	Vector3 end = p_ctx.read_vector(p_node_idx, 1);

	Ref<ProcCurves> curves;
	curves.instantiate();

	curves->positions.push_back(start);
	curves->positions.push_back(end);
	curves->radii.push_back(0.01f);
	curves->radii.push_back(0.01f);

	curves->curve_offsets.push_back(0);
	curves->curve_offsets.push_back(2);
	curves->curve_types.push_back(static_cast<int32_t>(CurveType::Poly));

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(curves);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- BezierSegmentNode ---

void BezierSegmentNode::_bind_methods() {
}

BezierSegmentNode::BezierSegmentNode() {
	// RESOLUTION IS SOCKET 0, so every one of ours was one place early -- Start
	// sat on Resolution, and the End was dropped off the end entirely. The
	// handles are named Start Handle and End Handle, not Handle 1 and 2, which
	// also cost them their by-name resolution.
	input_defs.push_back({ "Resolution", SocketType::Int, 16 });
	input_defs.push_back({ "Start", SocketType::Vector, Vector3(-1, 0, 0) });
	input_defs.push_back({ "Start Handle", SocketType::Vector, Vector3(-0.5f, 0.5f, 0) });
	input_defs.push_back({ "End Handle", SocketType::Vector, Vector3(0, 0, 0) });
	input_defs.push_back({ "End", SocketType::Vector, Vector3(1, 0, 0) });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
}

void BezierSegmentNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// Shifted by one: Resolution took socket 0.
	const int resolution = CLAMP(p_ctx.read_int(p_node_idx, 0), 1, 4096);
	Vector3 p0 = p_ctx.read_vector(p_node_idx, 1);
	Vector3 h1 = p_ctx.read_vector(p_node_idx, 2);
	Vector3 h2 = p_ctx.read_vector(p_node_idx, 3);
	Vector3 p1 = p_ctx.read_vector(p_node_idx, 4);

	Ref<ProcCurves> curves;
	curves.instantiate();

	if (p_ctx.graph != nullptr && p_ctx.graph->is_blender_frame()) {
		// BLENDER STORES THE HANDLES AND HANDS DOWNSTREAM THE EVALUATED
		// POLYLINE -- `resolution` segments per span. Everything after this
		// node samples that polyline, not the analytic cubic, so emitting the
		// four control points and evaluating them exactly gave points that were
		// close but never equal: the difference is the tessellation, and
		// Resolution is what controls it.
		for (int i = 0; i <= resolution; i++) {
			const float t = (float)i / (float)resolution;
			const float u = 1.0f - t;
			curves->positions.push_back(u * u * u * p0 + 3.0f * u * u * t * h1 +
					3.0f * u * t * t * h2 + t * t * t * p1);
			curves->radii.push_back(0.01f);
		}
		curves->curve_offsets.push_back(0);
		curves->curve_offsets.push_back(resolution + 1);
		curves->curve_types.push_back(static_cast<int32_t>(CurveType::Poly));
		curves->curve_cyclic.push_back(0);

		Ref<ProcGeometry> bgeo;
		bgeo.instantiate();
		bgeo->set_curves(curves);
		p_ctx.write_geometry(p_node_idx, 0, bgeo);
		return;
	}

	curves->positions.push_back(p0);
	curves->positions.push_back(h1);
	curves->positions.push_back(h2);
	curves->positions.push_back(p1);
	curves->radii.push_back(0.01f);
	curves->radii.push_back(0.01f);
	curves->radii.push_back(0.01f);
	curves->radii.push_back(0.01f);

	curves->curve_offsets.push_back(0);
	curves->curve_offsets.push_back(4);
	curves->curve_types.push_back(static_cast<int32_t>(CurveType::Bezier));

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(curves);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- CurveToMeshNode ---

void CurveToMeshNode::_bind_methods() {
}

CurveToMeshNode::CurveToMeshNode() {
	// BLENDER'S FOUR, IN BLENDER'S ORDER AND SPELLING. Ours put an invented
	// "Resolution" at index 2, which is Blender's SCALE, and "Closed" at 3,
	// which is Fill Caps -- so Fill Caps fell off the end and both real inputs
	// drove something else. "Path"/"Profile" also matched nothing by name.
	input_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Profile Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Scale", SocketType::Float, 1.0f });
	input_defs.push_back({ "Fill Caps", SocketType::Bool, false });
	// OURS, kept after Blender's so no Blender socket index can reach them.
	// Blender takes its resolution from the curve's own resolution attribute
	// rather than a socket, which ProcCurves does not carry yet.
	input_defs.push_back({ "Resolution", SocketType::Int, 32 });
	input_defs.push_back({ "Closed", SocketType::Bool, false });
	output_defs.push_back({ "Mesh", SocketType::Geometry, Variant() });
}

void CurveToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> path_geo = p_ctx.read_geometry(p_node_idx, 0);
	Ref<ProcGeometry> profile_geo = p_ctx.read_geometry(p_node_idx, 1);
	// SOCKET 0 IS THE CURVE. This read the resolution from it -- a geometry
	// socket -- so the value was whatever a geometry read yields as an int.
	const float profile_scale = p_ctx.read_float(p_node_idx, 2);
	bool fill_caps = p_ctx.read_bool(p_node_idx, 3);
	int resolution = CLAMP(p_ctx.read_int(p_node_idx, 4), 2, 4096);
	bool force_closed = p_ctx.read_bool(p_node_idx, 5);

	// Validate: both inputs must have curves
	if (path_geo.is_null() || path_geo->get_curves().is_null() ||
			path_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}
	if (profile_geo.is_null() || profile_geo->get_curves().is_null() ||
			profile_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}

	Ref<ProcCurves> path_curves = path_geo->get_curves();
	Ref<ProcCurves> profile_curves = profile_geo->get_curves();

	// Sample the profile curve to get its cross-section points
	int profile_start = profile_curves->curve_offsets[0];
	int profile_end = profile_curves->curve_offsets[1];
	int profile_point_count = profile_end - profile_start;
	int profile_samples = MAX(profile_point_count, 3);

	Vector<Vector3> profile_points;
	profile_points.resize(profile_samples);
	for (int i = 0; i < profile_samples; i++) {
		float pt = static_cast<float>(i) / profile_samples;
		// Blender's Scale sizes the profile. It is a FIELD there, per path
		// point; applied uniformly here, which is stated rather than hidden.
		profile_points.write[i] = profile_curves->evaluate_position(0, pt) * profile_scale;
	}

	// Sample path at resolution evenly spaced t values
	int path_samples = resolution;

	Ref<ProcMesh> mesh;
	mesh.instantiate();

	// Build rings: for each path sample, transform profile points by the Frenet frame
	// Total vertices = path_samples * profile_samples
	int total_verts = path_samples * profile_samples;
	mesh->positions.resize(total_verts);
	Vector3 *pos_w = mesh->positions.ptrw();

	// Track previous normal for consistent frame propagation
	Vector3 prev_normal;
	bool has_prev_normal = false;

	// Determine if path is closed: explicit option OR auto-detect from endpoints
	Vector3 path_start_pos = path_curves->evaluate_position(0, 0.0f);
	Vector3 path_end_pos = path_curves->evaluate_position(0, 1.0f);
	float close_dist = path_start_pos.distance_to(path_end_pos);
	bool path_closed = force_closed || (close_dist < 0.01f);

	for (int pi = 0; pi < path_samples; pi++) {
		// For closed paths: sample [0, 1) so last ring doesn't overlap first
		// For open paths: sample [0, 1] normally
		float path_t = path_closed
				? static_cast<float>(pi) / path_samples
				: static_cast<float>(pi) / (path_samples - 1);

		Vector3 path_pos = path_curves->evaluate_position(0, path_t);
		Vector3 tangent = path_curves->evaluate_tangent(0, path_t);

		if (tangent.length_squared() < 1e-12f) {
			tangent = Vector3(0, 0, 1);
		}
		tangent = tangent.normalized();

		// Build Frenet-like frame with rotation minimization
		Vector3 normal;
		if (!has_prev_normal) {
			// Initial frame: pick an up vector not parallel to tangent
			Vector3 up = Vector3(0, 1, 0);
			if (fabsf(tangent.dot(up)) > 0.999f) {
				up = Vector3(1, 0, 0);
			}
			normal = tangent.cross(up).normalized();
			has_prev_normal = true;
		} else {
			// Propagate previous normal: project onto plane perpendicular to tangent
			normal = prev_normal - tangent * tangent.dot(prev_normal);
			if (normal.length_squared() < 1e-12f) {
				// Fallback if degenerate
				Vector3 up = Vector3(0, 1, 0);
				if (fabsf(tangent.dot(up)) > 0.999f) {
					up = Vector3(1, 0, 0);
				}
				normal = tangent.cross(up).normalized();
			} else {
				normal = normal.normalized();
			}
		}

		Vector3 binormal = normal.cross(tangent).normalized();
		prev_normal = normal;

		// Transform each profile point by the frame
		for (int ri = 0; ri < profile_samples; ri++) {
			Vector3 pp = profile_points[ri];
			// Profile is in its local space; use x as normal direction, y as binormal, z as tangent
			Vector3 world_pos = path_pos + normal * pp.x + binormal * pp.z + tangent * pp.y;
			pos_w[pi * profile_samples + ri] = world_pos;
		}
	}

	// Build triangle indices connecting adjacent rings
	// For closed paths, also connect last ring to first ring
	int ring_connections = path_closed ? path_samples : (path_samples - 1);
	int quad_count = ring_connections * profile_samples;
	mesh->indices.resize(quad_count * 6);
	int32_t *idx_w = mesh->indices.ptrw();
	int idx = 0;

	for (int pi = 0; pi < ring_connections; pi++) {
		int pi_next = (pi + 1) % path_samples; // Wraps for closed paths
		for (int ri = 0; ri < profile_samples; ri++) {
			int ri_next = (ri + 1) % profile_samples;

			int v00 = pi * profile_samples + ri;
			int v01 = pi * profile_samples + ri_next;
			int v10 = pi_next * profile_samples + ri;
			int v11 = pi_next * profile_samples + ri_next;

			// Triangle 1 (CW winding for Godot front-face)
			idx_w[idx++] = v00;
			idx_w[idx++] = v01;
			idx_w[idx++] = v10;

			// Triangle 2
			idx_w[idx++] = v01;
			idx_w[idx++] = v11;
			idx_w[idx++] = v10;
		}
	}

	// Add end caps for open paths
	if (!path_closed && fill_caps) {
		int cap_center_start = mesh->positions.size();
		// Start cap: center point at first path position
		mesh->positions.push_back(path_start_pos);
		// End cap: center point at last path position
		mesh->positions.push_back(path_end_pos);
		int cap_center_end = cap_center_start + 1;

		// Resize indices to fit cap triangles
		int cap_tris = profile_samples * 2; // start + end caps
		int old_idx_count = mesh->indices.size();
		mesh->indices.resize(old_idx_count + cap_tris * 3);
		int32_t *cap_idx = mesh->indices.ptrw() + old_idx_count;
		int ci = 0;

		// Start cap — faces outward (toward -tangent), CW winding
		for (int ri = 0; ri < profile_samples; ri++) {
			int ri_next = (ri + 1) % profile_samples;
			cap_idx[ci++] = cap_center_start;
			cap_idx[ci++] = ri_next;
			cap_idx[ci++] = ri;
		}

		// End cap — faces outward (toward +tangent), CW winding
		int last_ring = (path_samples - 1) * profile_samples;
		for (int ri = 0; ri < profile_samples; ri++) {
			int ri_next = (ri + 1) % profile_samples;
			cap_idx[ci++] = cap_center_end;
			cap_idx[ci++] = last_ring + ri;
			cap_idx[ci++] = last_ring + ri_next;
		}
	}

	mesh->recalculate_normals();

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_mesh(mesh);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- CurveToPointsNode ---

void CurveToPointsNode::_bind_methods() {
}

CurveToPointsNode::CurveToPointsNode() {
	input_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Count", SocketType::Int, 10 });
	input_defs.push_back({ "Length", SocketType::Float, 0.1f });
	output_defs.push_back({ "Points", SocketType::Geometry, Variant() });
	// Blender hands back the frame at each point as well as the point. Not yet
	// computed here -- declared so the links land, and they read as zero.
	output_defs.push_back({ "Tangent", SocketType::Vector, Variant() });
	output_defs.push_back({ "Normal", SocketType::Vector, Variant() });
	output_defs.push_back({ "Rotation", SocketType::Rotation, Variant() });
}

void CurveToPointsNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> in_geo = p_ctx.read_geometry(p_node_idx, 0);
	int count = CLAMP(p_ctx.read_int(p_node_idx, 1), 1, 100000);

	if (in_geo.is_null() || in_geo->get_curves().is_null() ||
			in_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}

	Ref<ProcCurves> curves = in_geo->get_curves();

	Ref<ProcPointCloud> pc;
	pc.instantiate();
	pc->positions.resize(count);
	pc->radii.resize(count);
	Vector3 *pos_w = pc->positions.ptrw();
	float *rad_w = pc->radii.ptrw();

	// BY LENGTH, not by parameter -- see ProcCurves::sample_uniform.
	PackedVector3Array sampled = curves->sample_uniform(0, count);
	for (int i = 0; i < count; i++) {
		pos_w[i] = i < sampled.size() ? sampled[i] : Vector3();
		rad_w[i] = 0.01f;
	}

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_pointcloud(pc);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- ResampleCurveNode ---

void ResampleCurveNode::_bind_methods() {
}

ResampleCurveNode::ResampleCurveNode() {
	// Count sat at socket 1, which is SELECTION -- a graph wiring a selection
	// set the sample count.
	input_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Selection", SocketType::Bool, true });
	input_defs.push_back({ "Mode", SocketType::Int, 0 });
	input_defs.push_back({ "Count", SocketType::Int, 32 });
	input_defs.push_back({ "Length", SocketType::Float, 0.1f });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
}

void ResampleCurveNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> in_geo = p_ctx.read_geometry(p_node_idx, 0);
	int count = CLAMP(p_ctx.read_int(p_node_idx, 3), 2, 100000);

	if (in_geo.is_null() || in_geo->get_curves().is_null() ||
			in_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}

	Ref<ProcCurves> src = in_geo->get_curves();

	Ref<ProcCurves> resampled;
	resampled.instantiate();

	resampled->positions.resize(count);
	resampled->radii.resize(count);
	Vector3 *pos_w = resampled->positions.ptrw();
	float *rad_w = resampled->radii.ptrw();

	// BY LENGTH, as Blender's Resample Curve does.
	PackedVector3Array sampled = src->sample_uniform(0, count);
	for (int i = 0; i < count; i++) {
		pos_w[i] = i < sampled.size() ? sampled[i] : Vector3();
		rad_w[i] = 0.01f;
	}

	resampled->curve_offsets.push_back(0);
	resampled->curve_offsets.push_back(count);
	// A resampled cyclic curve is still cyclic. Dropping the flag turned a
	// closed loop into an open arc whose points then spread over [0, total]
	// instead of round the loop.
	resampled->curve_cyclic.push_back(src->is_cyclic(0) ? 1 : 0);
	resampled->curve_types.push_back(static_cast<int32_t>(CurveType::Poly));

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(resampled);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- SetCurveRadiusNode ---

void SetCurveRadiusNode::_bind_methods() {
}

SetCurveRadiusNode::SetCurveRadiusNode() {
	// Radius sat on SELECTION.
	input_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Selection", SocketType::Bool, true });
	input_defs.push_back({ "Radius", SocketType::Float, 0.005f });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
}

void SetCurveRadiusNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> in_geo = p_ctx.read_geometry(p_node_idx, 0);
	float radius = p_ctx.read_float(p_node_idx, 2);

	if (in_geo.is_null() || in_geo->get_curves().is_null() ||
			in_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}

	Ref<ProcCurves> dup = in_geo->get_curves()->duplicate_curves();

	int n = dup->radii.size();
	float *rad_w = dup->radii.ptrw();
	for (int i = 0; i < n; i++) {
		rad_w[i] = radius;
	}

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(dup);

	// Preserve other components from input
	if (in_geo->get_mesh().is_valid()) {
		geo->set_mesh(in_geo->get_mesh());
	}
	if (in_geo->get_pointcloud().is_valid()) {
		geo->set_pointcloud(in_geo->get_pointcloud());
	}
	if (in_geo->get_instances().is_valid()) {
		geo->set_instances(in_geo->get_instances());
	}

	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- TrimCurveNode ---

void TrimCurveNode::_bind_methods() {
}

TrimCurveNode::TrimCurveNode() {
	// Blender carries TWO Start/End pairs -- factor at 2 and 3, length at 4 and
	// 5 -- and the mode picks which. Ours had one pair beginning at socket 1,
	// which is Selection.
	input_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
	input_defs.push_back({ "Selection", SocketType::Bool, true });
	input_defs.push_back({ "Start", SocketType::Float, 0.0f });
	input_defs.push_back({ "End", SocketType::Float, 1.0f });
	input_defs.push_back({ "Start_001", SocketType::Float, 0.0f });
	input_defs.push_back({ "End_001", SocketType::Float, 1.0f });
	output_defs.push_back({ "Curve", SocketType::Geometry, Variant() });
}

void TrimCurveNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> in_geo = p_ctx.read_geometry(p_node_idx, 0);
	// The FACTOR pair, sockets 2 and 3. The length pair at 4 and 5 belongs to
	// the mode this node does not implement.
	float trim_start = CLAMP(p_ctx.read_float(p_node_idx, 2), 0.0f, 1.0f);
	float trim_end = CLAMP(p_ctx.read_float(p_node_idx, 3), 0.0f, 1.0f);

	if (in_geo.is_null() || in_geo->get_curves().is_null() ||
			in_geo->get_curves()->curve_count() == 0) {
		Ref<ProcGeometry> empty;
		empty.instantiate();
		p_ctx.write_geometry(p_node_idx, 0, empty);
		return;
	}

	// Ensure start <= end
	if (trim_start > trim_end) {
		float tmp = trim_start;
		trim_start = trim_end;
		trim_end = tmp;
	}

	Ref<ProcCurves> src = in_geo->get_curves();

	// Use the original point count as the sample count for the trimmed result
	int src_start = src->curve_offsets[0];
	int src_end = src->curve_offsets[1];
	int src_count = src_end - src_start;
	int count = MAX(src_count, 2);

	Ref<ProcCurves> trimmed;
	trimmed.instantiate();

	trimmed->positions.resize(count);
	trimmed->radii.resize(count);
	Vector3 *pos_w = trimmed->positions.ptrw();
	float *rad_w = trimmed->radii.ptrw();

	for (int i = 0; i < count; i++) {
		float local_t = (count > 1) ? static_cast<float>(i) / (count - 1) : 0.0f;
		float t = trim_start + local_t * (trim_end - trim_start);
		pos_w[i] = src->evaluate_position(0, t);
		rad_w[i] = 0.01f;
	}

	trimmed->curve_offsets.push_back(0);
	trimmed->curve_offsets.push_back(count);
	trimmed->curve_types.push_back(static_cast<int32_t>(CurveType::Poly));

	Ref<ProcGeometry> geo;
	geo.instantiate();
	geo->set_curves(trimmed);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

} // namespace godot