#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