3D Dubins Path Solver

Find the shortest curvature-constrained path between two oriented points in 3D space. Also known as CLC (Curve-Line-Curve) or CSC (Curve-Straight-Curve) paths. Applications include directional drilling, UAV/drone flight planning, autonomous vehicles, underwater vehicle routing, pipeline routing, and robotics.

Try it now — no API key needed

Interactive 3D solver — adjust inputs, click Run, see solutions plotted in 3D. Or copy and paste any of the examples below.

Python

Requires the requests library (pip install requests). Save as a .py file and run, or paste into a Python/Jupyter session.

import requests

response = requests.post("https://api.welleng.org/api/v1/clc/solve", json={
    "pairs": [{
        "pos0": [0, 0, 0],    "vec0": [0, 0, 1],
        "pos3": [100, 50, 600], "vec3": [0.2, 0.1, 0.97],
        "r": 573
    }],
    "solver": "analytical",
    "shortest_only": True
})

data = response.json()
for sol in data["results"][0]["solutions"]:
    print(f"Path length: {sol['md']:.1f}m, arc: {sol['total_arc_deg']:.1f}°")
curl (Terminal / Command Prompt)

Open a terminal (macOS/Linux) or Command Prompt (Windows) and paste the following. No installation needed — curl is built into all modern operating systems.

curl -s --compressed -X POST https://api.welleng.org/api/v1/clc/solve \
  -H "Content-Type: application/json" \
  -d '{
    "pairs": [{
      "pos0": [0, 0, 0],    "vec0": [0, 0, 1],
      "pos3": [100, 50, 600], "vec3": [0.2, 0.1, 0.97],
      "r": 573
    }],
    "solver": "analytical",
    "shortest_only": true
  }'
JavaScript (Node.js or browser)

Works in Node.js 18+ (which has built-in fetch) or in a browser console. No dependencies required.

const response = await fetch("https://api.welleng.org/api/v1/clc/solve", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    pairs: [{
      pos0: [0, 0, 0],    vec0: [0, 0, 1],
      pos3: [100, 50, 600], vec3: [0.2, 0.1, 0.97],
      r: 573
    }],
    solver: "analytical",
    shortest_only: true,
    // compute: "auto"   // optional: "auto" (default) | "cpu" | "gpu" — see Compute override
  })
});

const data = await response.json();
data.results[0].solutions.forEach(sol =>
  console.log(`Path length: ${sol.md.toFixed(1)}m, arc: ${sol.total_arc_deg.toFixed(1)}°`)
);

Up to 100 pairs and 20 requests/day without a key. Request a free API key for higher limits. Full API docs →

How to cite

If you use this solver or the underlying method in published work, please cite:

This guide is a living product document and is updated as the service evolves — cite the paper and software DOIs above, not this page.

What is a CLC path?

A CLC (Curve-Line-Curve) path is the 3D equivalent of a Dubins path (detailed overview) — the shortest route between two oriented points when constrained to a minimum turning radius. In 2D, Dubins[3] proved that optimal paths consist of circular arcs and straight lines. The CLC type connects two configurations using an arc, a straight tangent line, then a second arc.

Naming conventions: This path type appears under many names in the literature. CLC (Curve-Line-Curve) and CSC (Curve-Straight-Curve) are equivalent. In Dubins path notation, the specific variants are RSR, RSL, LSR, LSL (R = right turn, S = straight, L = left turn). In directional drilling, the same geometry is called a build-hold-drop or turn-hold-turn trajectory. The other Dubins family — CCC (three consecutive arcs with no straight segment) — is not covered by this solver.

Extending this to 3D is significantly harder. In 2D there are only 6 possible path types; in 3D the turning circle at each point becomes a full family of circles — every possible turning direction perpendicular to the heading. Finding which pair of circles (one at the start, one at the target) can be connected by a straight tangent line is a challenging geometric search problem.

Each configuration is a point in space with a direction (position + unit vector). The solver finds multiple valid CLC paths connecting them, ranked by total arc angle or path length.

Points and conventions

The path passes through four points, each with a position and direction:

PointPositionDirectionRole
P0pos0vec0Start — given as input
P1p1vec1End of first arc, start of straight line
P2p2vec2End of straight line, start of second arc
P3pos3vec3Target — given as input

Path segments

CLC path diagram showing the three segments with turning circles
  1. C1 — circular arc from P0 to P1, with turning radius r. The arc departs P0 in direction vec0 and arrives at P1 in direction vec1.
  2. L — straight line from P1 to P2. Direction is vec1 = vec2 (tangent continuity).
  3. C2 — circular arc from P2 to P3, with turning radius r3. The arc departs P2 in direction vec2 and arrives at P3 in direction vec3.

The key constraint: vec1 = vec2 — the direction at P1 must equal the direction at P2, since the straight line segment connects them.

3D example — two solutions for the same start and target

3D visualization showing two distinct CLC paths between the same endpoints

Blue: gentle path (133° total arc). Red: wrap-around path (356° total arc). Both are valid CLC solutions connecting the same P0 and P3.

Input

FieldDescription
pos0P0 — start position [x, y, z]
vec0P0 — start direction (unit vector)
pos3P3 — target position [x, y, z]
vec3P3 — target direction (unit vector) — desired arrival heading
rTurning radius for C1 (arc at P0)
r3Turning radius for C2 (arc at P3) — defaults to r if omitted

Output (per solution)

FieldDescription
p1, vec1P1 — junction between C1 and L
p2, vec2P2 — junction between L and C2
arc1_degArc angle of C1 (P0 → P1) in degrees
arc2_degArc angle of C2 (P2 → P3) in degrees
total_arc_degarc1 + arc2 in degrees
L_lengthLength of straight segment (P1 → P2)
mdTotal path length: L1 + L2 + L3 where L1 = arc1 length, L2 = line length, L3 = arc2 length. (In drilling: measured depth.)
error_degAngular matching error — lower is more accurate
r_used, r3_usedActual turning radii used (may differ from input if radius sweep is active)

Multiple solutions

Many geometries have more than one valid CLC path. For example, a path can wrap the "short way" or the "long way" around a turning circle. The solver finds multiple solutions where they exist, ranked by your chosen criterion (minimize: "arc" for gentlest path, or "md" for shortest total path length).

Design philosophy: completeness over raw speed

This solver finds every CLC solution — including the tight-arc solutions that a seeded numerical search can miss. Since v0.8.0 that completeness is structural, not searched-for: the Sawaryn closed form reduces the problem to the roots of a degree-10 polynomial, and every root is enumerated and forward-verified. There is no scan to under-sample, no seed to place, and no mode to configure. The method — including the corrected polynomial and the all-roots batch solve — is published: Corcutt (2026), DOI: 10.5281/zenodo.21130979.

Iterative Newton-style 3D CSC solvers converge to one solution per starting seed — a mathematical property of local root-finding, which is why multi-start strategies exist. Solutions with small arcs are the ones most easily lost this way, and small arcs are exactly the configurations that matter in directional drilling: target approaches, small mid-well corrections, dead-reckoning cleanup after a survey. A path planner that returns "no solution" or a 5 km long-way-around path on a configuration with a 500 m short-arc solution is a production hazard — silently producing plans that look plausible but aren't optimal, and that may cost days of rig time before anyone notices.

We continue to benchmark internally against current public methods — notably Xu, Baryshnikov & Sung (2025)[14], the most recent 3D CSC reformulation.

At batch scale the engine returns every CLC solution at ~0.022 ms/pair on CPU and ~0.005 ms/pair on GPU (RTX 3080 Ti, sustained to millions of pairs per request).

Accuracy

The default analytical solver returns machine-precision solutions — the geometric error at the arc-line junctions is zero up to floating-point rounding (~1e-12). There are no user-tunable accuracy tiers; every solve is exact.

Performance (v0.9, RTX 3080 Ti): engine ~0.022 ms/pair CPU / ~0.005 ms/pair GPU (GPU dispatch from ~3,000 pairs; sustained to millions of pairs per request — large batches auto-chunk). End-to-end through the API, auth + metering + response assembly included: ~0.13 ms/pair on 1,000–5,000-pair batches with the binary response format, ~0.27 ms/pair as JSON. The compute field in the response reports which backend ran.

Published benchmark comparison

These are the fastest per-pair CLC/CSC solve timings we are aware of in the published literature (basis: timings as reported by each paper's authors — see the note below the table; we know of no same-machine head-to-head). Comparison against the only other analytical method that enumerates all valid solutions:

Baez et al (2024)[1]This engine, batch CPUThis engine, batch GPU
Time per pair25 ms (as reported)0.022 ms (measured)0.005 ms (measured)
Ratio of reported timings~1,100×~5,000×
HardwareMacBook M2 ProRyzen (single process)RTX 3080 Ti
LanguageMathematicaPython / numpyPython / torch
MethodanalyticalSawaryn closed form (degree-10 roots), open welleng engine + commercial serving layer
All solutionsYes (mean 4, up to 7)Yes — every polynomial root, forward-verified

Comparison basis — different hardware, languages and implementations, so the ratios compare reported timings, not a same-machine benchmark. Baez et al. report 0.02499 ± 0.00097 s per query in Mathematica on an Apple M2 Pro (arXiv:2405.08710, §V). Our figures are measured on a 20,000-pair random drilling-geometry battery (AMD Ryzen 9 5950X single process; GPU = RTX 3080 Ti), engine time only. On that battery this engine returns a mean of 3.8 solutions per pair (77% four-solution — sampling-distribution dependent, so not directly comparable to Baez's 84%). Iterative numerical methods (e.g. [24] in Baez) report 11.0 ± 11.6 s per query — six orders of magnitude slower than batched GPU solves.

Land-on-line targets (landing)

Instead of a fixed point+direction, a pair may target a line — pass landing: {"a": [..], "b": [..], "mode": "segment"|"line"} (heel and toe of a planned lateral) in place of pos3/vec3. The solver (Sawaryn 2021, Appendix C — validated against the paper's Example 4) finds every along-line distance k where a curve-curve biarc lands tangentially on the line. segment keeps landings between a and b; line allows overshoot past the toe. Each solution reports landing_point, k and on_segment. The search runs forward of a only — to allow landings short of the heel, move a back along the same line. With an API key (paid tier), combine landing with r_scale_min/r_scale_max to sweep the K×K r/r3 permutation grid in one request — every feasible landing across your DLS envelope, ranked. The inc/azi filter composes with landing pairs too (the arrival tangent is the line direction). Try it interactively: the CLC tool, "Land on line".

a (heel, k = 0) b (toe) "line" mode p1, t1 (start pose) arc 1 (R1) arc 2 (R2) p4 = a + k·t4 another root k (solved)

The landing problem: from a fixed start pose, find every along-line distance k where a curve–curve biarc (no hold section) arrives tangent to the line. The line is the planned lateral (heel→toe); multiple roots are normal — the API returns them all, ranked, with on_segment flagging which land within the lateral.

Solver engine

Since v0.8.0 every request is served by a single engine: the Sawaryn closed-form analytical solve (published method, open welleng library), with a vectorized and GPU-accelerated serving layer. It returns every CLC branch per pair at machine precision. The legacy analytical (scan-based) and toroidal (grid-based) backends are retired; the solver request field is accepted for compatibility and ignored — all values behave identically. Batch throughput is roughly 200,000 solves per second on the serving hardware; a million-case scan returns in seconds.

Completeness modes (retired)

No-op since v0.8.0. The completeness field (fast/complete) tuned the retired scan-based solver's alternate-path recovery. The closed-form engine always returns every solution, so both values behave identically; the field is accepted for wire compatibility only.

Compute override: auto / cpu / gpu

The request accepts an optional compute field (auto | cpu | gpu) that overrides the automatic CPU/GPU dispatch. Small batches always run on CPU (GPU launch overhead would dominate).

ValueBehaviour
auto (default)GPU from ~3,000 pairs when the card is available, otherwise CPU. Falls back to CPU transparently during GPU-maintenance windows.
cpuForces the CPU path regardless of pair count. Useful for self-isolating a suspected GPU-only bug — if a request fails or returns surprising solutions on GPU, repeat with "compute": "cpu" and compare.
gpuDemands the GPU path. During a GPU-maintenance window this returns 503 with an ETA and Retry-After (use auto for transparent CPU fallback instead); on a deployment without CUDA it serves on CPU and says so in the response.

The compute field in the response always echoes the backend that actually ran, so you can confirm the override took effect.

Response format: json vs numpy

Set format: "numpy" in the request to receive a packed binary response (Content-Type application/octet-stream) in place of JSON. This is an opt-in; the default is still "json".

At batch scale, JSON encoding dominates server time and payload size. The numpy format ships a flat float32 array (76 bytes per solution) plus a compact JSON header — roughly 10× smaller on the wire and ~2× faster end-to-end:

N pairsjson end-to-endnumpy end-to-end
1,0000.27 s0.13 s
5,0001.2 s0.70 s

(Localhost, v0.9. Network transport over the tunnel adds size-dependent time; the wire-size advantage grows with batch size.)

Wire format (schema version 1)

[ 2 bytes:  uint16 LE — header JSON length ]
[ H bytes:  UTF-8 JSON header                ]
[ S * 4:    int32 LE  — pair_idx             ]
[ S*K * 4:  float32 LE — sol rows (S x K)    ]

The header JSON carries n_sols (S), n_cols (K, currently 18), columns (ordered list of column names), schema_version, plus the usual response metadata (total_pairs, total_solutions, total_ms, compute, solver, solver_version, auth).

Column layout (indices correspond to positions in the (S, K) float32 array):

IndexColumnMeaning
0–2p1x, p1y, p1zJunction between first arc and line
3–5vec1x, vec1y, vec1zTangent direction along the line (= vec2)
6–8p2x, p2y, p2zJunction between line and second arc
9arc1_radFirst arc angle (radians)
10arc2_radSecond arc angle (radians)
11total_arc_radSum of arc1 + arc2
12rotation1_radFirst-arc rotation angle (drilling: toolface of first curve)
13rotation2_radSecond-arc rotation angle (drilling: toolface of second curve)
14L_lengthLength of the straight line segment
15mdTotal path length (drilling: measured depth)
16r_usedFirst-arc turning radius
17r3_usedSecond-arc turning radius

Fields dropped from the JSON format because they are derivable (client converts radians → degrees when needed), always zero for the analytical solver (error_deg, error_rad), or duplicated (vec2 equals vec1 for any CLC path).

Decoder (Python, 5 lines)

import json, struct, numpy as np, requests

r = requests.post("https://api.welleng.org/api/v1/clc/solve", json={
    "pairs": [...], "format": "numpy",
}, headers={"X-API-Key": "wek_..."}).content

hlen = struct.unpack_from("<H", r, 0)[0]
header = json.loads(r[2:2+hlen])
n, k = header["n_sols"], header["n_cols"]
pair_idx = np.frombuffer(r, dtype="<i4", count=n, offset=2+hlen)
arr = np.frombuffer(r, dtype="<f4", count=n*k, offset=2+hlen+n*4).reshape(n, k)
# arr[pair_idx == i] gives the rows for input pair i

Use json (the default) for interactive, small-batch, or browser clients; use numpy for high-throughput batch workloads where download size and decode speed matter.

Request format: JSON vs binary (application/octet-stream)

Symmetric counterpart to the numpy response. Submit a packed binary body instead of JSON. At 500K pairs this shaves ~55 MB of upload and a few seconds of server-side validation.

Available on every tier (demo, free, standard, premium) — same pairs-per-request caps as JSON mode. No extra entitlement needed.

Set Content-Type: application/octet-stream on the POST. The body is:

[ 2 bytes:  uint16 LE — header JSON length      ]
[ H bytes:  UTF-8 JSON header                   ]
[ N * 14 * 4: float32 LE — pair rows (N × 14)   ]

Per-row columns:

IndexColumn
0–2pos0_x, pos0_y, pos0_z
3–5vec0_x, vec0_y, vec0_z
6–8pos3_x, pos3_y, pos3_z
9–11vec3_x, vec3_y, vec3_z
12r
13r3 (or NaN to inherit from r)

The header JSON carries the same top-level fields as a JSON request: solver, minimize, shortest_only, completeness, r_scale_min, r_scale_max, n_r_steps, accuracy, compute (auto / cpu / gpu), format (response format — json or numpy, independent of request format), and filter. Plus two required framing fields: schema_version: 1 and n_pairs: N.

Filter block works in binary mode — put it in the header JSON exactly as in a JSON request. Schema v1 does not support per-pair filter overrides in binary mode; if you need them, submit via JSON. (Schema v2 with per-pair override columns is planned for when demand materialises.)

Encoder (Python, 8 lines)

import json, struct, numpy as np, requests

pairs = np.zeros((N, 14), dtype="<f4")       # fill in columns
pairs[:, 3:6] = [0, 0, 1]                     # vec0
pairs[:, 12] = 573                            # r
pairs[:, 13] = np.nan                         # r3 inherits

header = json.dumps({"schema_version": 1, "n_pairs": N,
                     "solver": "analytical", "format": "numpy"}).encode()
body = struct.pack("<H", len(header)) + header + pairs.tobytes()
requests.post(URL, data=body,
              headers={"Content-Type": "application/octet-stream",
                       "X-API-Key": KEY})

Encoder with filter (Python)

The filter block goes in the header JSON, exactly as in a JSON request — nothing else changes:

header = json.dumps({
    "schema_version": 1, "n_pairs": N,
    "solver": "analytical",
    "format": "numpy",                  # response format — numpy-in / numpy-out
    "filter": {
        "inc_range_deg": {"min": 0, "max": 60},
        "azi_range_deg": {"min": 40, "max": 95},
        "return_pair_status": False,
    },
}).encode()
body = struct.pack("<H", len(header)) + header + pairs.tobytes()
# POST as above. The numpy response carries the filter counts in the
# header JSON just like a JSON response.

The response comes back in whatever format the header requested — so a numpy-in / numpy-out round-trip is the fastest path end-to-end for large batches.

Interactive schema: Swagger UI documents both content types for the /api/v1/clc/solve endpoint — click the application/octet-stream tab under Request body to see the field-level description.

Radius sweep

r_scale_min and r_scale_max are ratio multipliers applied to the turning radius r. For example, with DLS = 3°/30 m the design radius is r ≈ 573 m:

r_scaleEffective radiusMeaning
0.9573 × 0.9 = 516 m10% tighter turns (DLS ≈ 3.33°/30 m)
1.0573 × 1.0 = 573 mDesign radius (DLS = 3.00°/30 m)
1.1573 × 1.1 = 630 m10% gentler turns (DLS ≈ 2.73°/30 m)

Set r_scale_min=0.9 and r_scale_max=1.1 with n_r_steps=5 and the solver sweeps K×K = 25 independent r/r3 permutations (5 scale steps on each arc's radius — asymmetric combinations included). If the design radius (1.0) falls within the range, the nearest grid point is snapped to it, so the design case is always in the sweep. The whole grid is solved in one vectorized batch call; sweeps work on point targets and landing pairs alike.

Radius sweep: solving at multiple radii in one GPU call

Useful for finding the optimal turning radius or checking feasibility at different curvatures. A minor increase in DLS (smaller radius) can result in a significantly shorter total path length. In drilling: optimising dogleg severity.

Inclination / azimuth filter

Free on every tier, including the no-key demo. The kernel cost is sub-μs per path, so there's no reason to paywall it — feel free to try it out. Reject CLC paths whose tangent ever strays outside an operational inc/azi envelope. Paths are filtered inside the solve call, before response assembly, so rejected paths never incur the per-solution response-build cost. For tight envelopes this is often a faster request than the unfiltered equivalent, not a slower one.

Coordinate convention: [x, y, z] = [E, N, V], V positive = down.

Defaults:

Tier access: the filter is a premium-tier feature; lower tiers get bypass_reason: "premium_feature_required" in the response meta. Since v0.8.0 it runs on every request regardless of the solver field value.

Basic request:

{
  "pairs": [ ... ],
  "solver": "analytical",
  "shortest_only": true,
  "filter": {
    "inc_range_deg": {"min": 0, "max": 60},
    "azi_range_deg": {"min": 40, "max": 95},
    "return_pair_status": true
  }
}

Restrict the envelope to a TVD window

By default the inc/azi envelope must hold over the whole path. Add a tvd_range to enforce it only where the path is within a true-vertical-depth window — portions shallower or deeper are unconstrained, and a path that never enters the window passes. Bounds are in the same length units as your coordinates (z = V, positive down), and either bound may be omitted for a one-sided window.

{
  "filter": {
    "inc_range_deg": {"min": 0, "max": 30},
    "tvd_range": {"min": 2000, "max": 3200}   // only steer-limit inside 2000-3200 TVD
  }
}

Multiple depth-banded rules

For different envelopes in different sections, send a rules list instead of the flat fields. Each rule pairs an optional typed window with its own inc/azi envelope, and a path must pass every rule. Two discriminators keep the shape extensible: the rule's type ("envelope" today) and the window's type ("tvd" today — a TVD window, but other scoping dimensions can be added later). Use either the flat form or rules, not both.

{
  "filter": {
    "rules": [
      {"type": "envelope", "window": {"type": "tvd", "max": 1500},
       "inc_range_deg": {"min": 0, "max": 15}},          // stay near-vertical to 1500
      {"type": "envelope", "window": {"type": "tvd", "min": 2800},
       "inc_range_deg": {"min": 85, "max": 95},
       "azi_range_deg": {"min": 40, "max": 95}}           // land horizontal on-azimuth below 2800
    ],
    "return_pair_status": true
  }
}

Every filtered response carries a filter block with:

FieldMeaning
appliedtrue when the filter ran; false when bypassed
bypass_reason"premium_feature_required" on tiers below the granting tier
paths_pre_filter / paths_filtered_out / paths_returnedPath-level counts
pairs_solvedPairs with at least one surviving CLC branch
pairs_unreachablePairs where the solver returned zero branches — geometry unreachable, independent of filter
pairs_all_filteredPairs where the solver found branches but all were rejected by the envelope — envelope too tight

pairs_unreachable vs pairs_all_filtered is the key diagnostic: the first means "no CLC exists for this geometry — widen r or move the target"; the second means "CLC exists but your envelope rejected it — relax the envelope." Both map to rejected pairs, but the fix is different.

Per-pair overrides. Optionally tighten (or loosen) individual pairs by supplying min_per_pair / max_per_pair arrays of length n_pairs. null at any slot inherits from the global min / max:

"filter": {
  "inc_range_deg": {
    "min": 0, "max": 90,                               // global baseline
    "max_per_pair": [null, 30, null, 30, null]          // pairs 1 & 3 use 30°, others 90°
  }
}

Per-pair status. Add "return_pair_status": true to get a per-pair pair_status array aligned to your input order, with values "solved", "filtered_out", or "unreachable". Omit it on large batches to reduce payload size.

Bypass cases (response has filter.applied=false)

One situation causes the filter to be skipped — the request still succeeds (200 OK) and the filter block is echoed back with applied=false and a bypass_reason:

bypass_reasonWhen
"premium_feature_required"The caller's tier does not include the filter. Results are returned unfiltered.

In a bypass the response carries your filter spec echoed back, unfiltered results, and no pair_status. Always check filter.applied before acting on results.

Drilling context

Coordinate system: The solver uses x = East, y = North, z = Down (TVD). Survey data in (North, East, TVD) must be transformed to (x, y, z) = (East, North, TVD) before submitting to the API.

DLS to radius: The turning radius r is derived from dogleg severity (DLS) and course length:

r = (180 / π) × (course_length / DLS)

where DLS is in degrees per course_length (e.g. °/30 m or °/100 ft). For example, DLS = 3°/30 m gives r ≈ 573 m.

Terminology mapping:

Solver termDrilling term
r, r3Turning radius (from DLS)
arc1, arc2Dogleg angles
md (total path length)Measured depth
rotation1, rotation2Toolface angles (referenced to high side, or north when inclination is near zero)

Try the solver

Sharing & privacy: the interactive tool's share links and JSON exports encode the complete case in the link/file itselfnothing is saved server-side. Solve inputs and results are computed and returned per request and never persisted (the service stores usage counts and timings only). A shared link recreates the case by re-solving it, not by fetching a stored copy.

The WellEng CLC Solver is available as a REST API — send a JSON request with your start and target configurations, get ranked CLC solutions back in milliseconds. No installation required.

The solver dispatches to GPU (NVIDIA RTX 3080 Ti) from ~3,000 pairs and handles up to 500,000 pairs per request (premium tier) — engine time ~0.005 ms/pair on GPU, ~0.022 on CPU; end-to-end expect ~0.13 ms/pair with the binary response format. Small batches run on CPU. The compute field in the response tells you which was used.

Quick start — curl example

curl -s --compressed -X POST https://api.welleng.org/api/v1/clc/solve \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_KEY" \
  -d '{
    "pairs": [{
      "pos0": [0, 0, 0], "vec0": [0, 0, 1],
      "pos3": [100, 50, 600], "vec3": [0.2, 0.1, 0.97],
      "r": 573
    }],
    "solver": "analytical",
    "shortest_only": true
  }'

Important: Always use https://api.welleng.org (no port number). Use --compressed to enable gzip — large batch responses compress ~5-10x, significantly reducing transfer time. Responses are gzip-encoded automatically when the client supports it.

Radius sweep

To test multiple turning radii, add r_scale_min, r_scale_max, and optionally n_r_steps:

{
  "pairs": [{"pos0": ..., "vec0": ..., "pos3": ..., "vec3": ..., "r": 573}],
  "solver": "analytical",
  "shortest_only": true,
  "r_scale_min": 0.8,
  "r_scale_max": 1.2,
  "n_r_steps": 5
}

This tests 5 r1-scales × 5 r2-scales = 25 permutations per pair. With the default shortest_only: true, one shortest solution is returned per permutation (so 25 solutions per pair); set shortest_only: false to receive every valid solution from each permutation. If n_r_steps is omitted, it defaults to 3 when a sweep is requested.

Background: the 3D Dubins problem

From 2D to 3D

In 1957, Lester Dubins proved that the shortest path between two oriented points in a plane, subject to a minimum turning radius, is composed of at most three segments — each either a circular arc (C) at the minimum radius or a straight line (S). This gives six possible Dubins path types: CSC (three variants) and CCC (three variants). The problem has elegant closed-form solutions in 2D and is widely used in robot motion planning, UAV path planning, and vehicle routing.

Extending Dubins paths to 3D is an open problem in computational geometry. The key difficulty: in 2D, each point has exactly two turning circles (left and right). In 3D, the set of all possible turning circles from a point forms a continuous family — every direction perpendicular to the heading defines a valid turning circle. Finding a valid CLC path requires identifying one circle at the start and one at the target that can be connected by a mutual tangent line — a search over a much larger space than the 2D case.

Classical approaches and their limitations

Several approaches have been proposed for 3D Dubins-like path planning:

Analytical constructions. The most directly relevant prior work is by Hota and Ghose (IISc Bangalore), who in their 2010 IROS paper[4] gave an explicit geometrical construction for the 3D CSC (Curve-Straight-Curve) path — the same path family as CLC. They proved that when the start and target are "sufficiently far apart," specifically ||P3 − P0|| > 4r, a CSC path is guaranteed to exist and can be computed without iteration. For smaller separations, the optimal path may be CCC or of the helicoidal type, which their construction does not cover. In a 2014 follow-up[5], the same authors extended this to UAV trajectory planning with wind and addressed a singular case at 180° turning angles using a cross-product reformulation. Hota and Ghose's work is the analytical foundation most relevant to the drilling CLC problem, and their 4r sufficient-distance condition is a useful rule of thumb for when a clean CSC solution will exist.

More recently, Baez, Navkar and Becker (2024)[1] reformulated the problem as an RRPRR inverse-kinematics system and used resultant elimination to give a closed-form treatment that removes the 4r sufficient-distance restriction and enumerates all up-to-seven valid CSC solutions for any valid configuration. This is the direct theoretical successor to Hota and Ghose on the 3D CSC side.

Other analytical work extends Dubins' 2D results to restricted 3D cases — Chitsaz and LaValle (2007)[2] characterised time-optimal paths for a Dubins airplane (constrained pitch), and Owen, Beard and McLain (2014)[8] provided practical implementations for fixed-wing UAVs. These are effective for their specific domains but don't generalise to arbitrary 3D orientations.

Iterative numerical methods. In directional drilling, the standard approach is a fixed-point iteration that adjusts toolface and dogleg angles until the endpoint converges — the compendium of such calculations based on the minimum curvature method is set out in Sawaryn & Thorogood (2005)[9]. The authors themselves acknowledge the limitations of the iterative scheme: "from a safety-critical-systems perspective, neither the above scheme nor that proposed by Liu and Shi[6] are completely satisfactory. In neither case is convergence proved, and no definitive statement is made regarding the conditions under which no solutions exist (e.g., when the radii of curvature are too large to hit the target). Further work is required." The follow-up Part 2 (Sawaryn & Tulceanu, 2009)[10] extends the treatment to steering and landing applications. The welleng[17] library's Connector class takes a related numerical-optimisation approach — a damped fixed-point iteration wrapped around scipy.optimize.minimize — and is reliable for realistic well geometries. However, iterative methods are inherently sequential (one pair at a time), limiting throughput for batch workloads.

The scaling problem. All of these approaches process one pair at a time. In applications like multi-target well planning, anti-collision checking, or real-time UAV re-routing, you may need to solve thousands to hundreds of thousands of pairs simultaneously. Sequential methods that take 5-15 ms per pair become impractical at this scale. This engine was built for batch throughput — roughly 0.005 ms/pair on GPU (~200,000 solves/second, sustained to millions of cases with automatic chunking), 0.022 ms/pair on CPU.

Our approach

This solver is designed for high-throughput batch workloads. Key properties clients can rely on:

The underlying method is published: Corcutt (2026), An Open, Vectorized Closed-Form Solver for the 3D Curve-Hold-Curve Point-to-Target Problem, DOI: 10.5281/zenodo.21130979 (see How to cite).

Applications

3D CLC paths appear in any domain where a vehicle or tool must travel between two oriented points with constrained curvature:

References

  1. Baez, V.M., Navkar, N. and Becker, A.T. (2024). "An Analytic Solution to the 3D CSC Dubins Path Problem". arXiv:2405.08710. Removes the 4r sufficient-distance restriction; shows up to 7 valid CSC solutions via RRPRR inverse-kinematics / resultant-elimination. arXiv · Code
  2. Chitsaz, H. and LaValle, S.M. (2007). "Time-optimal paths for a Dubins airplane". IEEE CDC. PDF
  3. Dubins, L.E. (1957). "On Curves of Minimal Length with a Constraint on Average Curvature, and with Prescribed Initial and Terminal Positions and Tangents". American Journal of Mathematics. Wikipedia · Grokipedia
  4. Hota, S. and Ghose, D. (2010). "Optimal Geometrical Path in 3D with Curvature Constraint". IEEE/RSJ IROS, pp. 113–118. Foundational analytical CSC construction in 3D; proves a CSC path exists whenever ||P3 − P0|| > 4r (their "sufficiently far" condition, §III Note 2). IEEE Xplore
  5. Hota, S. and Ghose, D. (2014). "Optimal Trajectory Planning for Unmanned Aerial Vehicles in Three-Dimensional Space". Journal of Aircraft, 51(2), pp. 681–688. Extends the 2010 construction to wind; resolves the 180° singularity via cross-product reformulation. DOI: 10.2514/1.C032245
  6. Liu, X. and Shi, Z. (2001). "Improved Method Makes a Soft Landing of Well Path". Oil & Gas Journal, October 2001, p. 48.
  7. Manyam, S.G., Kumar, D., Darbha, S. et al. (2024). "On the Reachability of 3-Dimensional Paths with a Prescribed Curvature Bound". arXiv:2403.18707. arXiv
  8. Owen, M., Beard, R. and McLain, T. (2014). "Implementing Dubins Airplane Paths on Fixed-Wing UAVs". Handbook of Unmanned Aerial Vehicles, Springer. Link
  9. Sawaryn, S.J. and Thorogood, J.L. (2005). "A Compendium of Directional Calculations Based on the Minimum Curvature Method". SPE 84246-PA. SPE Drilling & Completion. OnePetro
  10. Sawaryn, S.J. and Tulceanu, M.A. (2009). "A Compendium of Directional Calculations Based on the Minimum Curvature Method — Part 2: Extension to Steering and Landing Applications". SPE 110014-PA. SPE Drilling & Completion. OnePetro
  11. Scholes, H. (1992). "Constant Curvature Method for Planning a 3-D Directional Well". SPE 24381. OnePetro
  12. Sun, Y. et al. (2021). "Subsea field layout optimization (Part I) — directional well trajectory planning based on 3D Dubins Curve". Journal of Petroleum Science and Engineering. ScienceDirect
  13. Váňa, P., Pěnička, R. and Faigl, J. (2020). "Minimal 3D Dubins Path with Bounded Curvature and Pitch Angle". IEEE ICRA. Decoupled pitch/yaw formulation with an NLP solver. PDF
  14. Xu, L., Baryshnikov, Y. and Sung, C. (2025). "Reparametrization of 3D CSC Dubins Paths Enabling 2D Search". arXiv:2503.11560. arXiv
  15. Wikipedia: Motion planning — broader context for path planning under curvature constraints.
  16. Wikipedia: Directional drilling — the primary application domain.
  17. welleng — open-source well engineering library by the same author, which motivated this work and now hosts the open solver core. Software DOI: 10.5281/zenodo.20968887
  18. Corcutt, J. (2026). "An Open, Vectorized Closed-Form Solver for the 3D Curve-Hold-Curve Point-to-Target Problem". Zenodo preprint. Corrects a transcription error in the Sawaryn (2021) polynomial; batched all-roots solve; radius-sweep feasibility hazard. DOI: 10.5281/zenodo.21130979

Interactive solver · API docs · [email protected] · Author · Guide v4.8