· updated Mar 29, 2026

Parsing Painted Lines and Taxiway Lights from apt.dat

X-PlaneBezier CurvesParsingGeometryFlight Simulation
Parsing Painted Lines and Taxiway Lights from apt.dat

[!ABSTRACT] TL;DR Linear features (row code 120) are painted taxiway markings and embedded lights. They use the same bezier node system as pavements but produce stroked paths instead of filled polygons. Each node carries a line type that styles the segment starting at that node. The catch: at sharp corners (split beziers), skipping coordinates loses type information and makes segments invisible.

What are linear features?

Walk around any airport and you’ll see paint on the ground. Yellow centerlines guide aircraft along taxiways. Hold short bars tell pilots where to stop before a runway. ILS critical area boundaries mark zones where vehicles must stay clear during instrument approaches. Blue edge lights line the taxiway borders at night.

These are not filled shapes. They are stroked paths — lines painted on the surface, or lights embedded into it.

X-Plane calls them linear features and stores them under row code 120:

120 Taxiway A centerline
111 47.464 -122.311 1 0
111 47.465 -122.311 1 0
112 47.466 -122.310 47.466 -122.311 4 102
115 47.467 -122.309 4 102

The format looks similar to pavements: a header line, then a sequence of nodes defining the path. But instead of closing into a polygon, it ends with 115 or 116 (open path terminator). And each node carries extra data — the line type and light type for the segment that follows it.

Pavements (110)Linear features (120)
GeometryFilled polygonStroked path
Terminates with113/114 (close ring)115/116 (end path)
Node extra dataOptional line/light typesLine type + light type
ResultTextured surfacePainted line or lights

The bezier curve system is identical. Same node codes, same control point mirroring. If you can parse pavements, you already have most of the work done. See parsing apt.dat geometry for the full breakdown of bezier math and the four connection types.

The node format

Nodes use the same 111–116 row codes as pavements, with trailing fields for style:

111 latitude longitude [line_type] [light_type]
112 latitude longitude ctrl_lat ctrl_lon [line_type] [light_type]

Both line_type and light_type are optional. When omitted, they default to 0.

There is a gotcha with single trailing values. Some apt.dat entries only provide one number after the coordinates. Is 111 47.464 -122.311 102 a line type of 102 or a light type of 102? The answer comes from the value ranges: line types max out at 92, light types start at 101. If the single value is >= 100, it is a light type.

Here is the disambiguation from the parser:

// From pathParser.ts — disambiguating single trailing value
let nodeLineType = 0;
let nodeLightType = 0;
if (tokens.length > 4) {
  // Both values present
  nodeLineType = parseInt(tokens[3]);
  nodeLightType = parseInt(tokens[4]);
} else if (tokens.length > 3) {
  // Single value: >= 100 means light type, otherwise line type
  const value = parseInt(tokens[3]);
  if (value >= 100) {
    nodeLightType = value;
  } else {
    nodeLineType = value;
  }
}

This works because the two ranges don’t overlap. The spec doesn’t explicitly call this out, but it is the only interpretation that produces correct results.

Line type reference

The line type determines what gets painted on the ground:

CodeAppearance
0Nothing (transparent)
1Solid yellow
2Broken yellow
3Double solid yellow
4Runway hold (bars + dashes)
5Other hold
6ILS hold
7ILS critical centerline
20Solid white
21Chequered white
22Broken white

Add 50 to any code for a black border variant: 51 is solid yellow with black border, 53 is double solid yellow with black border. The border helps visibility on light-coloured concrete where a plain yellow line might wash out.

Light type reference

Embedded taxiway lights:

CodeAppearanceDirectionality
101Green centerlineBidirectional
102Blue edgeOmnidirectional
103Amber holdUnidirectional
104Amber pulsatingUnidirectional
105Alternating amber/greenBidirectional
106Red stop barOmnidirectional
107Green centerlineUnidirectional
108Alternating amber/greenUnidirectional

Directionality matters for rendering. Bidirectional lights are visible from both directions along the taxiway. Unidirectional lights face one way only, typically toward approaching traffic. Omnidirectional lights are visible from all angles.

How types flow

The type on a node defines the segment starting at that node.

Node A (type 1)  ──segment (type 1)──>  Node B (type 4)  ──segment (type 4)──>  Node C

Node A’s type applies to the line from A to B. Node B’s type applies from B to C. Simple enough. The type travels forward.

If a node doesn’t specify a type, it defaults to 0 (transparent). This is intentional. Some linear features have gaps where no paint or lights should appear. A default of 0 means “this segment is invisible” — not “this data is missing.”

Segment splitting

A single linear feature can have multiple line types along its length:

120 Taxiway centerline with hold
111 ... 1 0      ← Solid yellow
111 ... 1 0      ← Solid yellow
111 ... 4 102    ← Hold bars + blue lights
111 ... 4 102    ← Hold bars + blue lights
115 ... 4 102    ← End

You can’t render this as one line with one style. You need to split it into segments where each segment has a consistent type, then render each one separately.

interface TypedCoord {
  pos: Point;
  lineType: number;
  lightType: number;
}

interface Segment {
  coords: Point[];
  lineType: number;
  lightType: number;
}

function splitByType(coords: TypedCoord[]): Segment[] {
  const segments: Segment[] = [];
  let segStart = 0;

  for (let i = 1; i < coords.length; i++) {
    if (coords[i].lineType !== coords[segStart].lineType ||
        coords[i].lightType !== coords[segStart].lightType) {
      // Type changed — end current segment, include boundary point
      segments.push({
        coords: coords.slice(segStart, i + 1).map(c => c.pos),
        lineType: coords[segStart].lineType,
        lightType: coords[segStart].lightType,
      });
      segStart = i;
    }
  }

  // Final segment
  segments.push({
    coords: coords.slice(segStart).map(c => c.pos),
    lineType: coords[segStart].lineType,
    lightType: coords[segStart].lightType,
  });

  return segments.filter(s => s.lineType > 0 || s.lightType > 0);
}

The boundary point (where the type changes) is included in both the ending segment and the starting segment. Without this overlap, you get a gap between segments. The final filter removes type-0 segments since those are transparent by design.

Bezier curves in linear features

The bezier rules are the same as for pavements. Straight lines between 111 nodes, quadratic beziers when one node is a 112, cubic beziers when both are 112. See the four connection types for the geometry.

What matters here is how types propagate through interpolated points. When you sample a bezier curve, you generate dozens of intermediate coordinates. Each one needs a type for the splitting algorithm to work correctly.

The rule: intermediate points inherit the starting node’s type. The last point gets the ending node’s type.

// Bezier from Node A (type 53) to Node B (type 0), 60 sample points
// Generated: [A, p1, p2, ..., p59, B]
// Types:     [53, 53, 53, ..., 53,  0]
//             ↑ inherit from A         ↑ B's own type

Node A’s type covers the curve itself. Node B’s type covers whatever segment comes after B. When the splitter runs, all the curve points land in the type-53 segment, and B becomes the first point of the next segment. The visual transition happens right at the node position.

Sharp corners and split beziers

Bezier curves are inherently smooth — the incoming and outgoing tangent at a node are opposite directions, which produces a continuous curve. Sometimes you need a sharp corner instead: a 90-degree turn, an abrupt direction change. The solution is a split bezier: two nodes at the same position with independent control points.

Split bezier

For the geometry explanation, see the split bezier section in the previous post. The consequence for linear features is worse than a visual spike. You lose type information.

Walk through this example with four nodes:

112 ... 53 102    ← Node A: type 53 (double yellow + black border)
112 ... 0  0      ← Node B: same position as A, type 0
111 ... 53 102    ← Node C: same position as A and B, type 53
111 ... 0  0      ← Node D: different position

Nodes A, B, and C share the same coordinates. If you detect the split bezier and skip the degenerate curve (correct), but also skip adding the coordinate to the output array (wrong), the type array has a gap. Node C’s type 53 never gets recorded. When the splitter processes the segment from C to D, it sees type 0 instead of type 53.

Type 0 is transparent. The line disappears.

Before and after fix

The fix: always add the coordinate, even when you skip the curve. The coordinate carries type information that the splitting algorithm needs.

// From pathParser.ts — split bezier handling
if (coordsEqual(previousPos, currentPos)) {
  // Same position: degenerate curve, don't draw it.
  // But STILL record the coordinate with its type.
  addCoord(currentPos, nodeLineType, nodeLightType);
} else {
  // Normal curve
  const points = calculateBezier(previousPos, control, currentPos);
  addBezierSegment(points, currentLineType, currentLightType,
                   nodeLineType, nodeLightType);
}

The coordsEqual check uses an epsilon of 1e-9 rather than exact floating-point equality. Coordinates that are practically the same position but differ by rounding noise should still be treated as split beziers.

Pavement edge markings

Pavements can carry line and light types on their boundary nodes. The format is the same:

110 1 0.25 0.00 Apron B
111 47.464 -122.311 0 0
111 47.465 -122.311 0 0
112 47.466 -122.310 47.465 -122.310 30 102
114 47.464 -122.310 30 102

The pavement boundary doubles as a linear feature. Instead of tracing two separate features (a polygon for the surface and a line on top of it), you define both at once. When parsing pavements, check if any nodes have non-zero line or light types. If they do, extract the boundary as a linear feature and run the same splitting logic described above.

Reference: gotchas

  1. Type 0 is intentional. It means “don’t paint anything here.” Don’t treat it as missing data — it is a deliberate gap in the marking.
  2. Lights and paint are independent. A node can have paint only (1 0), lights only (0 102), both (1 102), or neither. You may need to render the same linear feature twice: once for paint, once for lights.
  3. Don’t deduplicate coordinates at split nodes. Each coordinate in the output array carries a type that the splitting algorithm depends on. Remove a coordinate and you remove its type.
  4. Types can change mid-curve. A bezier from type 1 to type 4 is valid. Intermediate points inherit the starting type, the endpoint carries the new type. The visual split happens at the node position.
  5. The first bezier node has no incoming curve. If a linear feature starts with 112, record its position and control point. The curve gets drawn when the second node is processed.

Resources