diff --git a/src/main/java/gov/usgs/earthquake/nshmp/model/GridSourcePlanar.java b/src/main/java/gov/usgs/earthquake/nshmp/model/GridSourcePlanar.java
new file mode 100644
index 0000000000000000000000000000000000000000..6d82e218598e231f73fd60f601007c47c5599856
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/model/GridSourcePlanar.java
@@ -0,0 +1,288 @@
+package gov.usgs.earthquake.nshmp.model;
+
+import static gov.usgs.earthquake.nshmp.Maths.PI_BY_2;
+import static gov.usgs.earthquake.nshmp.Maths.TO_RADIANS;
+import static gov.usgs.earthquake.nshmp.Maths.TWO_PI;
+import static gov.usgs.earthquake.nshmp.Maths.hypot;
+import static java.lang.Math.min;
+import static java.lang.Math.sin;
+import static java.lang.Math.sqrt;
+import static java.lang.Math.tan;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import com.google.common.math.DoubleMath;
+
+import gov.usgs.earthquake.nshmp.data.XySequence;
+import gov.usgs.earthquake.nshmp.fault.FocalMech;
+import gov.usgs.earthquake.nshmp.fault.surface.RuptureScaling;
+import gov.usgs.earthquake.nshmp.fault.surface.RuptureSurface;
+import gov.usgs.earthquake.nshmp.geo.Location;
+import gov.usgs.earthquake.nshmp.geo.LocationVector;
+import gov.usgs.earthquake.nshmp.geo.Locations;
+import gov.usgs.earthquake.nshmp.mfd.Mfd;
+import gov.usgs.earthquake.nshmp.model.GridLoader.RuptureData;
+
+/**
+ * Planar gridded seismicity source. For initial use with subduction gridded
+ * seismicity.
+ *
+ * The internal MFD will go up to Mmax for the entire parent rupture set, but
+ * the highest magnitudes will often have zero rate.
+ *
+ * @author U.S. Geological Survey
+ */
+class GridSourcePlanar extends PointSource {
+
+  final RuptureSet<? extends Source> parent;
+  final SourceType type;
+  final Location loc;
+  final Mfd mfd;
+  final List<Rupture> ruptures;
+
+  /**
+   * Constructs a new planar grid earthquake source. This is a simple model that
+   * does not simulate finiteness (e.g. rupture rRup values will differ from rJB
+   * only by virtue of the depth of the source).
+   *
+   * @param parent {@code RuptureSet}
+   * @param type of source, as supplied from a parent {@code RuptureSet}
+   * @param loc <code>Location</code> of the point source
+   * @param mfd magnitude frequency distribution of the source
+   * @param mechWtMap <code>Map</code> of focal mechanism weights
+   * @param rupScaling rupture scaling model that may, or may not, impose an rJB
+   *        distance correction
+   * @param depthModel specifies magnitude cutoffs and associated weights for
+   *        different depth-to-top-of-ruptures
+   * @param surface optional rupture surface instance for use by subclasses
+   */
+  GridSourcePlanar(
+      RuptureSet<PointSource> parent,
+      SourceType type,
+      Location loc,
+      Mfd mfd,
+      Map<FocalMech, Double> mechWtMap,
+      RuptureScaling rupScaling,
+      DepthModel depthModel,
+      List<RuptureData> ruptureData) {
+
+    super(parent, type, loc, mfd, mechWtMap, rupScaling, depthModel, Optional.empty());
+
+    this.parent = parent;
+    this.type = type;
+    this.loc = loc;
+    this.mfd = mfd;
+
+    this.ruptures = initRuptures(loc, mfd, ruptureData);
+  }
+
+  private List<Rupture> initRuptures(
+      Location loc,
+      Mfd mfd,
+      List<RuptureData> ruptureData) {
+
+    List<Rupture> ruptures = new ArrayList<>(ruptureData.size());
+    XySequence mr = mfd.data();
+    for (int i = 0; i < ruptureData.size(); i++) {
+      PlanarSurface surface = new PlanarSurface(loc, ruptureData.get(i));
+      Rupture rupture = new RegularRupture(mr.x(i), mr.y(i), 0, surface);
+      ruptures.add(rupture);
+    }
+    return ruptures;
+  }
+
+  @Override
+  public String name() {
+    return "GridSourcePlanar: " + formatLocation(loc);
+  }
+
+  static final String FORMAT = "%.3f, %.3f";
+
+  static String formatLocation(Location loc) {
+    return String.format(FORMAT, loc.longitude, loc.latitude);
+  }
+
+  @Override
+  public int size() {
+    return ruptures.size();
+  }
+
+  @Override
+  public Rupture get(int index) {
+    return ruptures.get(index);
+  }
+
+  /**
+   * Overridden to return the ID of the parent grid rupture set. All point
+   * sources instantiated for a grid source will have the same ID. Note that
+   * {@code PointSource}s may be retrieved by index using
+   * {@link GridRuptureSet#source(int)}.
+   */
+  @Override
+  public int id() {
+    return parent.id();
+  }
+
+  @Override
+  public SourceType type() {
+    return type;
+  }
+
+  /**
+   * The location of the point source, irrespective of any distance corrections
+   * that might be applied to attendant ruptures and ignoring the supplied site
+   * {@code Location}.
+   */
+  @Override
+  public Location location(Location site) {
+    return loc;
+  }
+
+  @Override
+  public List<Mfd> mfds() {
+    return List.of(mfd);
+  }
+
+  static class PlanarSurface implements RuptureSurface {
+
+    final Location location;
+    final double strike;
+    final double dip;
+    final double dipRad;
+    final double zTor;
+    final double zBor;
+    final double length;
+    final double width;
+    final double widthH;
+
+    // Four corners of rupture:
+    // top trace: p1 --> p2
+    // bot trace: p4 <-- p3
+    Location p1, p2, p3, p4;
+
+    PlanarSurface(Location location, RuptureData rupData) {
+
+      this.location = location;
+
+      this.strike = rupData.strike;
+      this.dip = rupData.dip;
+      this.dipRad = dip * TO_RADIANS;
+      this.zTor = rupData.zTor;
+      this.zBor = rupData.zBor;
+      this.length = rupData.length;
+
+      double strikeRad = strike * TO_RADIANS;
+
+      // forward and back strike vectors
+      LocationVector svF = LocationVector.create(strikeRad, length / 2.0, 0.0);
+      LocationVector svB = LocationVector.reverseOf(svF);
+
+      // down-dip and up-dip azimuths
+      double ddAz = (strikeRad + PI_BY_2) % TWO_PI;
+      double udAz = (strikeRad - PI_BY_2) % TWO_PI;
+
+      // down-dip
+      double ddΔV = zBor - location.depth;
+      double ddΔH = ddΔV / tan(dipRad);
+      double ddW = ddΔV / sin(dipRad);
+      LocationVector ddV = LocationVector.create(ddAz, ddΔH, -ddΔV);
+      Location ddP = Locations.location(location, ddV);
+
+      // up-dip
+      double udΔV = location.depth - zTor;
+      double udΔH = udΔV / tan(dipRad);
+      double udW = udΔV / sin(dipRad);
+      LocationVector udV = LocationVector.create(udAz, udΔH, udΔV);
+      Location udP = Locations.location(location, udV);
+
+      this.p1 = Locations.location(udP, svB);
+      this.p2 = Locations.location(udP, svF);
+      this.p3 = Locations.location(ddP, svF);
+      this.p4 = Locations.location(ddP, svB);
+
+      this.width = ddW + udW;
+      this.widthH = ddΔH + udΔH;
+    }
+
+    @Override
+    public Distance distanceTo(Location loc) {
+      // NOTE no NSHMP style distance corrections here
+
+      double rX = Locations.distanceToLineFast(p1, p2, loc);
+      double rSeg = Locations.distanceToSegmentFast(p1, p2, loc);
+
+      // simple footwall case
+      boolean isVertical = (dip() > 89.0);
+      if (rX <= 0.0 || isVertical) {
+        return Distance.create(rSeg, hypot(rSeg, zTor), rX);
+      }
+
+      // otherwise, we're on the hanging wall...
+
+      // compute rRup as though we're between endpoints
+      double rCutTop = tan(dipRad) * zTor;
+      double rCutBot = tan(dipRad) * zBor + widthH;
+      double rRup = (rX > rCutBot) ? hypot(rX - widthH, zBor) : (rX < rCutTop) ? hypot(rX,
+          zTor) : hypot(rCutTop, zTor) + (rX - rCutTop) * sin(dipRad);
+
+      // test if we're normal to trace or past its endpoints
+      boolean offEnd = DoubleMath.fuzzyCompare(rSeg, rX, 0.00001) > 0;
+
+      if (offEnd) {
+        // distance from surface projection of ends/caps of fault
+        double rJB = min(Locations.distanceToSegmentFast(p1, p4, loc),
+            Locations.distanceToSegmentFast(p2, p3, loc));
+        double rY = sqrt(rSeg * rSeg - rX * rX);
+        // rRup is the hypoteneuse of rRup (above) and rY
+        return Distance.create(rJB, hypot(rRup, rY), rX);
+      }
+
+      double rJB = (rX > widthH) ? rX - widthH : 0.0;
+      return Distance.create(rJB, rRup, rX);
+    }
+
+    @Override
+    public double strike() {
+      return strike;
+    }
+
+    @Override
+    public double dip() {
+      return dip;
+    }
+
+    @Override
+    public double dipDirection() {
+      return (strike + 90.0) % 360.0;
+    }
+
+    @Override
+    public double length() {
+      return length;
+    }
+
+    @Override
+    public double width() {
+      return width;
+    }
+
+    @Override
+    public double area() {
+      return length * width;
+    }
+
+    @Override
+    public double depth() {
+      return zTor;
+    }
+
+    @Override
+    public Location centroid() {
+      return location;
+    }
+  }
+
+}