import json
import os
import arcpy
_PATH_KEYS = ("curvePaths", "paths")
_RING_KEYS = ("curveRings", "rings")
# Field types that are never copy: managed by the geodatabase or the shape itself.
_SKIP_FIELD_TYPES = {"OID", "Geometry", "GlobalID"}
#Return the coordinate key present in an Esri JSON geometry dict
def _geometry_key(geom_json):
for key in _PATH_KEYS + _RING_KEYS:
if key in geom_json:
return key
return None
def _segment_endpoint(element):
if isinstance(element, dict):
# Single-key dict; grab whichever key it uses.
params = next(iter(element.values()))
return params[0]
return element
def _split_path(path):
if len(path) < 2:
return
previous = path[0]
for element in path[1:]:
yield [previous, element]
previous = _segment_endpoint(element)
def _segment_geometries(geometry, preserve_curves=True):
geom_json = geometry.JSON
if not geom_json:
return []
parsed = json.loads(geom_json)
key = _geometry_key(parsed)
if key is None:
return []
# Preserve the source's spatial reference and Z/M awareness on every piece.
template = {}
for carried in ("spatialReference", "hasZ", "hasM"):
if carried in parsed:
template[carried] = parsed[carried]
# Output is always a polyline
segments = []
for path in parsed[key]:
for segment in _split_path(path):
piece = dict(template)
has_curve = any(isinstance(part, dict) for part in segment)
if has_curve and not preserve_curves:
# Collapse the arc to its chord: start vertex to end vertex.
segment = [_segment_endpoint(part) for part in segment]
has_curve = False
piece["curvePaths" if has_curve else "paths"] = [segment]
try:
segments.append(arcpy.AsShape(piece, True))
except Exception:
# Fall back to a straight chord if the JSON round-trip fails
# on this particular segment rather than losing the feature.
chord = dict(template)
chord["paths"] = [[_segment_endpoint(p) for p in segment]]
segments.append(arcpy.AsShape(chord, True))
return segments
# transfer field names present in both, excluding gdb-managed and shape fields.
def _transferable_fields(in_features, out_features):
out_names = {f.name.upper() for f in arcpy.ListFields(out_features)}
names = []
for field in arcpy.ListFields(in_features):
if field.type in _SKIP_FIELD_TYPES:
continue
if field.required:
# Shape_Length, Shape_Area and friends -- recalculated on insert.
continue
if field.name.upper() not in out_names:
continue
names.append(field.name)
return names
def split_line_at_vertices(in_features, out_feature_class,
preserve_curves=True, drop_zero_length=True):
"""
Split polyline or polygon features into one feature per vertex-to-vertex
segment. Returns the output feature class path.
"""
describe = arcpy.Describe(in_features)
if describe.shapeType not in ("Polyline", "Polygon"):
raise ValueError(
"Input must be polyline or polygon; got {0}.".format(
describe.shapeType))
out_path, out_name = os.path.split(out_feature_class)
if not out_path:
raise ValueError("Output must be a full path, not just a name.")
source = describe.catalogPath if hasattr(describe, "catalogPath") \
else in_features
arcpy.management.CreateFeatureclass(
out_path,
out_name,
"POLYLINE",
template=source,
has_m="SAME_AS_TEMPLATE" if describe.hasM else "DISABLED",
has_z="SAME_AS_TEMPLATE" if describe.hasZ else "DISABLED",
spatial_reference=describe.spatialReference,
)
if not arcpy.ListFields(out_feature_class, "ORIG_FID"):
arcpy.management.AddField(out_feature_class, "ORIG_FID", "LONG")
carried = _transferable_fields(in_features, out_feature_class)
oid_field = describe.OIDFieldName
read_fields = ["SHAPE@", oid_field] + carried
write_fields = ["SHAPE@", "ORIG_FID"] + carried
total_in = 0
total_out = 0
skipped = 0
arcpy.SetProgressor("default", "Splitting features at vertices...")
with arcpy.da.SearchCursor(in_features, read_fields) as search, \
arcpy.da.InsertCursor(out_feature_class, write_fields) as insert:
for row in search:
geometry = row[0]
total_in += 1
if geometry is None:
skipped += 1
continue
attributes = list(row[1:])
segments = _segment_geometries(geometry, preserve_curves)
# Matches the Esri tool: a feature with no intermediate vertices
# passes through as a single output feature.
if not segments:
insert.insertRow([geometry] + attributes)
total_out += 1
continue
for segment in segments:
if drop_zero_length and segment.length == 0:
skipped += 1
continue
insert.insertRow([segment] + attributes)
total_out += 1
if total_in % 1000 == 0:
arcpy.SetProgressorLabel(
"Processed {0:,} features -> {1:,} segments".format(
total_in, total_out))
arcpy.AddMessage(
"Split {0:,} input features into {1:,} segments.".format(
total_in, total_out))
if skipped:
arcpy.AddWarning(
"Skipped {0:,} null or zero-length segments.".format(skipped))
return out_feature_class
class Toolbox(object):
def __init__(self):
self.label = "Split Line At Vertices (Basic)"
self.alias = "splitlinebasic"
self.tools = [SplitLineAtVertices]
class SplitLineAtVertices(object):
def __init__(self):
self.label = "Split Line At Vertices (Basic)"
self.description = (
"Splits lines or polygon boundaries into one feature per "
"vertex-to-vertex segment. Equivalent to the Advanced-license "
"Split Line At Vertices tool, using only Basic-license "
"functionality.")
self.canRunInBackground = False
def getParameterInfo(self):
in_features = arcpy.Parameter(
displayName="Input Features",
name="in_features",
datatype="GPFeatureLayer",
parameterType="Required",
direction="Input")
in_features.filter.list = ["Polyline", "Polygon"]
out_fc = arcpy.Parameter(
displayName="Output Feature Class",
name="out_feature_class",
datatype="DEFeatureClass",
parameterType="Required",
direction="Output")
preserve = arcpy.Parameter(
displayName="Preserve true curves",
name="preserve_curves",
datatype="GPBoolean",
parameterType="Optional",
direction="Input")
preserve.value = True
drop_zero = arcpy.Parameter(
displayName="Drop zero-length segments",
name="drop_zero_length",
datatype="GPBoolean",
parameterType="Optional",
direction="Input")
drop_zero.value = True
return [in_features, out_fc, preserve, drop_zero]
def isLicensed(self):
return True
def updateMessages(self, parameters):
out_fc = parameters[1]
if out_fc.valueAsText:
name = os.path.basename(out_fc.valueAsText)
if name and name[0].isdigit():
out_fc.setErrorMessage(
"Feature class names cannot start with a number.")
def execute(self, parameters, messages):
split_line_at_vertices(
parameters[0].valueAsText,
parameters[1].valueAsText,
preserve_curves=parameters[2].value,
drop_zero_length=parameters[3].value,
)