Skip to content

Utils

Module for internal utility functions to share between modules

CLASS DESCRIPTION
PolylineEditor

Allow simple polyline editing using indexes and slices

Vector

Simple Vector implementation that takes a start and end point (uses Spherical notation for theta and phi).

FUNCTION DESCRIPTION
box_on_point

Build a rectangular box on a point

center_circle

Create a circle using a center point and a radius

convert_schema

Convert a Schema from one format to another

export_project_lyrx

Pull all layers from a project file and output them in a directory as lyrx files

export_project_maps

Pull all layers from a project file and output them in a directory as mapx files

get_subtype_count

Get the subtype counts for a Table or FeatureClass

get_subtype_counts

Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

iter_parts

Get a part iterator for a Polyline

iter_points

Get a point iterator for a Polyline

nat

Natural sort key for use in string sorting

patch_schema_rules

Patch an exported Schema doc by re-linking attribute rules to table names

print

Print a message to the ArcGIS Pro message queue and stdout

shortest_path

Find the shortest path or paths given a source point, target point and network of Polylines

split_at_points

Split lines at provided points

split_lines_at_points

Split a Polyline or Sequence/Iterable of polylines at provided points

two_point_circle

Create a circle using a center point and an end point

vector_at

Get the vector of the line at the given point (p-delta -> p+delta)

vectors_at

Get the vector of the line at the given point (p-delta -> p+delta)

PolylineEditor

PolylineEditor(polyline: Polyline)

Allow simple polyline editing using indexes and slices

multipart polylines will be indexed on global point index,

    [[p1: 3pts], [p2: 2pts]] ->  [0, 1, 2, 3, 4]
                                  ^^p1^^^|^p2^^

METHOD DESCRIPTION
align

Adjust points of the polyline to be coincident with the target line of they are within tolerance units

append

Append a point to the last part of the Polyline

append_part

Add a part to the polyline

copy

Return a copy of the current editor with the active polyline state as the original polyline

count

Get the count of points in the line that match the input point

dedupe

Remove all duplicate points in a line keeping last instance (returns the number of points removed)

discard

Silently remove points without raising an error if it doesn't exist

extend

Extend the last part of the polyline with the points

generalize

Generalize the polyline

index

Get the global index of the first instance of the specified point, raise a ValueError if the point is not in the Polyline

insert

Insert a point at the specified index

intersections

Iterable of Point Intersections between this line and the other

merge_lines

Merge a sequence of Polylines into one Polyline (uses union so each line becomes a part)

move

Move the polyline along a Vector

orenent_with

Alter the direction of the polyline to match the direction of the input line (only works for lines that overlap)

pop

Remove a point from a polyline at the specified index (default: -1)

pop_part

pop a part from the polyline (default: -1)

project_as

Project the polyline in the given reference

remove

Remove the fist occurrence of the point from the polyline

reset

Revert all changes to polyline and restore the original geometry

reverse

Reverse the polyline parts and the points of each part of the polyline

split_at_angle

Split the polyline at points where the instantaneous angle (radians) is greater than the specified angle (radians)

ATTRIBUTE DESCRIPTION
centroid

Centroid of the feature if it is within the feature (same as measure@50%)

TYPE: PointGeometry

first_point

PointGeometry of the Polyline's firstPoint

TYPE: PointGeometry

last_point

PointGeometry of the Polyline's lastPoint

TYPE: PointGeometry

original

The original Polyline given to the PolylineEditor

TYPE: Polyline

part_editors

Get PolylineEditors for each part in the line

TYPE: list[PolylineEditor]

parts

A list of parts of the Polyline as Polylines (singlepart polylines will contain one part)

TYPE: list[Polyline]

segments

Get all 2-point segments that make up the line

TYPE: list[Polyline]

true_centroid

Center of gravity of the feature (not always in the line)

TYPE: PointGeometry

Source code in src/arcpie/utils.py
1072
1073
1074
def __init__(self, polyline: Polyline) -> None:
    self._orig_polyline = polyline
    self.polyline = polyline

centroid property

centroid: PointGeometry

Centroid of the feature if it is within the feature (same as measure@50%)

first_point property writable

first_point: PointGeometry

PointGeometry of the Polyline's firstPoint

last_point property writable

last_point: PointGeometry

PointGeometry of the Polyline's lastPoint

original property

original: Polyline

The original Polyline given to the PolylineEditor

part_editors property writable

part_editors: list[PolylineEditor]

Get PolylineEditors for each part in the line

parts property writable

parts: list[Polyline]

A list of parts of the Polyline as Polylines (singlepart polylines will contain one part)

segments property

segments: list[Polyline]

Get all 2-point segments that make up the line

true_centroid property

true_centroid: PointGeometry

Center of gravity of the feature (not always in the line)

align

align(
    line: Polyline | PolylineEditor,
    *,
    tolerance: float = 0.01,
) -> None

Adjust points of the polyline to be coincident with the target line of they are within tolerance units If multiple points are within the tolerance, the closest is picked

Source code in src/arcpie/utils.py
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
def align(self, line: Polyline | PolylineEditor, *, tolerance: float = 0.01) -> None:
    """Adjust points of the polyline to be coincident with the target line of they are within `tolerance` units 
    If multiple points are within the tolerance, the closest is picked
    """
    if isinstance(line, Polyline):
        line = PolylineEditor(line)
    _other_points = list(line)
    for idx, point in enumerate(self):
        nearest = min(_other_points, key=lambda p: p.distanceTo(point))
        if nearest.distanceTo(point) <= tolerance:
            self[idx] = nearest

append

append(point: Point | PointGeometry) -> None

Append a point to the last part of the Polyline

Source code in src/arcpie/utils.py
1304
1305
1306
1307
1308
1309
1310
1311
1312
def append(self, point: Point | PointGeometry) -> None:
    """Append a point to the last part of the Polyline"""
    _points = list(self.part_editors[-1])

    _points.append(self._cast_point(point))
    _parts = self.parts[:-1]

    _parts.append(self.from_points(_points))
    self.polyline = self.merge_lines(_parts)

append_part

append_part(part: Polyline | PolylineEditor) -> None

Add a part to the polyline

Source code in src/arcpie/utils.py
1397
1398
1399
1400
1401
1402
1403
1404
1405
def append_part(self, part: Polyline | PolylineEditor) -> None:
    """Add a part to the polyline"""
    _parts = self.part_editors
    if isinstance(part, PolylineEditor):
        new_parts = part.part_editors
    else:
        new_parts = PolylineEditor(part).part_editors
    _parts.extend(new_parts)
    self.polyline = self.merge_lines(p.polyline for p in _parts)

copy

copy() -> PolylineEditor

Return a copy of the current editor with the active polyline state as the original polyline

Source code in src/arcpie/utils.py
1341
1342
1343
def copy(self) -> PolylineEditor:
    """Return a copy of the current editor with the active polyline state as the original polyline"""
    return PolylineEditor(self.polyline)

count

count(point: Point | PointGeometry) -> int

Get the count of points in the line that match the input point

Source code in src/arcpie/utils.py
1336
1337
1338
1339
def count(self, point: Point | PointGeometry) -> int:
    """Get the count of points in the line that match the input point"""
    point = self._cast_point(point)
    return list(self).count(point)

dedupe

dedupe(keep: Literal['first', 'last'] = 'last') -> int

Remove all duplicate points in a line keeping last instance (returns the number of points removed)

Note

If a duplciate is found in a seperate part, it is not considered a duplicate and will be kept setting keep to 'last' will remove duplicates from the end of the line first

Source code in src/arcpie/utils.py
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
def dedupe(self, keep: Literal['first', 'last'] = 'last') -> int:
    """Remove all duplicate points in a line keeping last instance (returns the number of points removed)

    Note:
        If a duplciate is found in a seperate part, it is not considered a duplicate and will be kept 
        setting `keep` to `'last'` will remove duplicates from the end of the line first
    """
    _parts = list(self.part_editors)
    _removed = 0
    for part in _parts:
        _points = list(part)
        if keep == 'first': _points.reverse()

        for point in filter(lambda point: _points.count(point) > 1, _points):
            _points.remove(point)
            _removed += 1

        if keep == 'first': _points.reverse()
        part.polyline = self.from_points(_points)

    self.polyline = self.merge_lines(p.polyline for p in _parts)
    return _removed

discard

discard(point: Point | PointGeometry) -> None

Silently remove points without raising an error if it doesn't exist

Source code in src/arcpie/utils.py
1349
1350
1351
1352
1353
1354
def discard(self, point: Point | PointGeometry) -> None:
    """Silently remove points without raising an error if it doesn't exist"""
    try:
        self.remove(point)
    except IndexError:
        return

extend

extend(points: Iterable[Point | PointGeometry]) -> None

Extend the last part of the polyline with the points

Source code in src/arcpie/utils.py
1314
1315
1316
1317
1318
1319
1320
1321
1322
def extend(self, points: Iterable[Point | PointGeometry]) -> None:
    """Extend the last part of the polyline with the points"""
    _points = list(self.part_editors[-1])

    _points.extend(self._cast_point(p) for p in points)
    _parts = self.parts[:-1]

    _parts.append(self.from_points(_points))
    self.polyline = self.merge_lines(_parts)

generalize

generalize(distance: float) -> None

Generalize the polyline

Source code in src/arcpie/utils.py
1393
1394
1395
def generalize(self, distance: float) -> None:
    """Generalize the polyline"""
    self.polyline = self.polyline.generalize(distance)

index

index(
    point: Point | PointGeometry,
    start: SupportsIndex = 0,
    stop: SupportsIndex | None = None,
) -> int

Get the global index of the first instance of the specified point, raise a ValueError if the point is not in the Polyline

Source code in src/arcpie/utils.py
1297
1298
1299
1300
1301
1302
def index(self, point: Point | PointGeometry, start: SupportsIndex = 0, stop: SupportsIndex | None = None) -> int:
    """Get the global index of the first instance of the specified point, raise a ValueError if the point is not in the Polyline"""
    for idx, p in enumerate(list(self)[start:stop]):
        if not point.disjoint(p):
            return idx
    raise ValueError(f'Point {point} not found in polyline')

insert

insert(
    index: SupportsIndex, point: Point | PointGeometry
) -> None

Insert a point at the specified index

Note

Inserts the point at the global point index, whichever part that may be in If you want to insert a point at a specific part index, use .parts to access the part and then insert the point there. This does not apply to single part features since the local part index is equal to the global index.

Source code in src/arcpie/utils.py
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
def insert(self, index: SupportsIndex, point: Point | PointGeometry) -> None:
    """Insert a point at the specified index

    Note:
        Inserts the point at the global point index, whichever part that may be in
        If you want to insert a point at a specific part index, use `.parts` to access the 
        part and then insert the point there. This does not apply to single part features since 
        the local part index is equal to the global index.
    """
    point = self._cast_point(point)
    part_idx, local_idx = self._part_at_index(index)

    _parts = list(self.parts)
    _new_part = list(self.part_editors[part_idx])
    _new_part.insert(local_idx, point)

    _parts[part_idx] = self.from_points(_new_part)
    self.polyline = self.merge_lines(_parts)

intersections

intersections(
    other: Polyline | PolylineEditor,
) -> Iterator[PointGeometry]

Iterable of Point Intersections between this line and the other

Note: Intersections are de-duplicated and returned as PointGeometry objects

Source code in src/arcpie/utils.py
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
def intersections(self, other: Polyline | PolylineEditor) -> Iterator[PointGeometry]:
    """Iterable of Point Intersections between this line and the other

    Note: Intersections are de-duplicated and returned as PointGeometry objects
    """
    if isinstance(other, PolylineEditor):
        other = other.polyline

    intersection = self.polyline.intersect(other, 1)
    if isinstance(intersection, PointGeometry) and intersection.isMultipart:
        intersection = Multipoint(Array([p for p in intersection]), self.polyline.spatialReference)

    if isinstance(intersection, Multipoint):
        seen: list[PointGeometry] = []
        for p in (PointGeometry(p, self.polyline.spatialReference) for p in intersection):
            if not any(p == s for s in seen):
                yield p
                seen.append(p)
    else:
        yield PointGeometry(intersection.centroid, self.polyline.spatialReference)

merge_lines classmethod

merge_lines(lines: Iterable[Polyline]) -> Polyline

Merge a sequence of Polylines into one Polyline (uses union so each line becomes a part)

Example
    lines = [[a,b,c,d], [e,f,g,h]]
    new = PolylineEditor.merge_lines(lines)
    new == Polyline([p1[a,b,c,d], p2[e,f,g,h]])
Source code in src/arcpie/utils.py
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
@classmethod
def merge_lines(cls, lines: Iterable[Polyline]) -> Polyline:
    """Merge a sequence of Polylines into one Polyline (uses `union` so each line becomes a part)

    Example:
        ```python
            lines = [[a,b,c,d], [e,f,g,h]]
            new = PolylineEditor.merge_lines(lines)
            new == Polyline([p1[a,b,c,d], p2[e,f,g,h]])
        ```
    """
    # Merge algorithm that keeps sequential touching lines in the same part, 
    # but allows for disjoint parts to remain disjoint
    _parts: list[list[PointGeometry]] = []
    _last_point: PointGeometry | None = None
    _ref: SpatialReference | None = None
    for line in lines:
        if _ref is None:
            _ref = line.spatialReference
        points = list(cls(line))
        if _last_point and not points[0].disjoint(_last_point):
            _parts[-1].extend(points)
        else:
            _parts.append(points)
        _last_point = points[-1]
    return Polyline(
        Array(
            Array(p.centroid for p in part) 
            for part in _parts
        ), 
        _ref
    )

move

move(vec: Vector) -> None

Move the polyline along a Vector

Source code in src/arcpie/utils.py
1416
1417
1418
1419
1420
1421
def move(self, vec: Vector) -> None:
    """Move the polyline along a Vector"""
    _parts = self.part_editors
    for i, part in enumerate(_parts):
        _parts[i].polyline = self.from_points(vec.translate(p) for p in part)
    self.polyline = self.merge_lines(p.polyline for p in _parts)

orenent_with

orenent_with(line: Polyline | PolylineEditor) -> None

Alter the direction of the polyline to match the direction of the input line (only works for lines that overlap)

Source code in src/arcpie/utils.py
1429
1430
1431
1432
1433
1434
1435
1436
def orenent_with(self, line: Polyline | PolylineEditor) -> None:
    """Alter the direction of the polyline to match the direction of the input line (only works for lines that overlap)"""
    if isinstance(line, PolylineEditor):
        line = line.polyline

    if not line.disjoint(self.polyline) and (int_part := line.intersect(self.polyline, 2)):
        if self.polyline.measureOnLine(int_part.firstPoint) > self.polyline.measureOnLine(int_part.lastPoint):
            self.reverse()

pop

pop(index: SupportsIndex = -1) -> PointGeometry

Remove a point from a polyline at the specified index (default: -1)

Source code in src/arcpie/utils.py
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
def pop(self, index: SupportsIndex = -1, /) -> PointGeometry:
    """Remove a point from a polyline at the specified index (default: -1)"""
    part_idx, local_idx = self._part_at_index(index)

    _parts = list(self.parts)
    _new_part = list(self.part_editors[part_idx])
    pt = _new_part.pop(local_idx)

    _parts[part_idx] = self.from_points(_new_part)
    self.polyline = self.merge_lines(_parts)
    return pt

pop_part

pop_part(index: SupportsIndex = -1) -> Polyline

pop a part from the polyline (default: -1)

Source code in src/arcpie/utils.py
1407
1408
1409
1410
1411
1412
1413
1414
def pop_part(self, index: SupportsIndex = -1, /) -> Polyline:
    """pop a part from the polyline (default: -1)"""
    _parts = self.part_editors
    if len(_parts) == 1:
        raise ValueError('cannot pop parts from a single part polyline')
    part = _parts.pop(index)
    self.polyline = self.merge_lines(p.polyline for p in _parts)
    return part.polyline

project_as

project_as(ref: SpatialReference | int) -> None

Project the polyline in the given reference

Source code in src/arcpie/utils.py
1423
1424
1425
1426
1427
def project_as(self, ref: SpatialReference | int) -> None:
    """Project the polyline in the given reference"""
    if isinstance(ref, int):
        ref = SpatialReference(ref)
    self.polyline = self.polyline.projectAs(ref)

remove

remove(point: Point | PointGeometry) -> None

Remove the fist occurrence of the point from the polyline

Source code in src/arcpie/utils.py
1345
1346
1347
def remove(self, point: Point | PointGeometry) -> None:
    """Remove the fist occurrence of the point from the polyline"""
    self.pop(self.index(point))

reset

reset() -> None

Revert all changes to polyline and restore the original geometry

Source code in src/arcpie/utils.py
1381
1382
1383
def reset(self) -> None:
    """Revert all changes to `polyline` and restore the original geometry"""
    self.polyline = self._orig_polyline

reverse

reverse() -> None

Reverse the polyline parts and the points of each part of the polyline

Example

[[a, b], [c, d]] -> [[d, c], [b, a]][a, b, c, d] -> [d, c, b, a]

Source code in src/arcpie/utils.py
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
def reverse(self) -> None:
    """Reverse the polyline parts and the points of each part of the polyline

    Example:
        [[a, b], [c, d]] -> [[d, c], [b, a]]
        [a, b, c, d] -> [d, c, b, a]
    """
    #self.polyline = self.merge_lines(self.from_points(reversed(list(part))) for part in reversed(self.part_editors))    
    rev = self.polyline.reverseOrientation() # type: ignore
    assert isinstance(rev, Polyline)
    self.polyline = rev

split_at_angle

split_at_angle(
    ang: float,
    units: Literal["degrees", "radians"] = "radians",
    *,
    tolerance: float = 0.1,
) -> list[Polyline]

Split the polyline at points where the instantaneous angle (radians) is greater than the specified angle (radians)

PARAMETER DESCRIPTION

ang

The target angle at a point to be split at

TYPE: float

units

Specify the units of the provided angle and tolerance

TYPE: Literal['degrees', 'radians'] DEFAULT: 'radians'

tolerance

+/- of target angle to trigger a split (default: 0.1rad/~5.7deg)

TYPE: float DEFAULT: 0.1

Source code in src/arcpie/utils.py
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
def split_at_angle(self, ang: float, units: Literal['degrees', 'radians'] = 'radians', *, tolerance: float = 0.1) -> list[Polyline]:
    """Split the polyline at points where the instantaneous angle (radians) is greater than the specified angle (radians)

    Args:
        ang: The target angle at a point to be split at
        units: Specify the units of the provided angle and tolerance
        tolerance: +/- of target angle to trigger a split (default: `0.1rad`/`~5.7deg`)
    """
    ang = abs(ang)
    tolerance = abs(tolerance)
    if units == 'degrees':
        ang = abs(math.radians(ang)%math.pi)
        tolerance = abs(math.radians(tolerance))

    segs: list[Polyline] = []
    for part in self.part_editors:
        last_split = 0
        for idx, point in enumerate(part[1:-1], start=1):
            l, r = vectors_at(part.polyline, point)
            if abs(l@r) < ang + tolerance:
                segs.append(self.from_points(part[last_split:idx+1]))
                last_split = idx

        if last_split != len(part)-1:
            segs.append(self.from_points(part[last_split:]))

    if self.polyline.hasCurves:
        # Use segment measure to preserve curves
        segs = [
            self.polyline.segmentAlongLine(
                self.polyline.measureOnLine(seg.firstPoint), 
                self.polyline.measureOnLine(seg.lastPoint)
            )
            for seg in segs
        ]
    return segs

Vector dataclass

Vector(
    tail: Point | PointGeometry,
    head: Point | PointGeometry,
    ref: SpatialReference | None = None,
)

Simple Vector implementation that takes a start and end point (uses Spherical notation for theta and phi).

If PointGeometries are passed as the head and tail points, the head will inherit the reference of the tail

ATTRIBUTE DESCRIPTION
x

The X component of the vector

TYPE: float

y

The Y component of the vector

TYPE: float

z

The Z component of the vector

TYPE: float

theta

The angle between the vector and the x-axis

TYPE: float

dist

The magnitute of the vector (distance b/w start and end)

TYPE: float

mid

The midpoint of the vector along its magnitude

TYPE: Point

ref

Ano optional spatial reference for all Vector geometry to inherit

TYPE: SpatialReference | None

METHOD DESCRIPTION
translate

Translate a point along the Vector (Vector tail is set to point location)

reverse

Reverse the Vector

add

Vector addition (LHS is origin)

subtract

Vector subtraction (LHS is origin)

dot

Dot product of two vectors (LHS is origin)

cross

Cross product of two vectors (LHS is origin)

scale

Scale a vector using a scalar

angle

Get angle between two vectors

__rshift__

Implements >> for use in translating points (vector must be LHS)

__neg__

Implements unary - operator for Vectors. Same as .reverse()

__add__

Implements + operator for Vectors. RHS Vector will be used as anchor

__sub__

Implements - operator for Vectors. RHS Vector will be used as anchor

__xor__

Implements ^ operator for Vectors (dot product).

__mul__

Implements * operator for Vectors. RHS can be Vector (cross product) or Scalar

__matmul__

Implements @ to determine accute angle between two vectors

head_geom property

head_geom: PointGeometry

Get a point geometry object for the vector head (end)

mid_geom property

mid_geom: PointGeometry

Get a point geometry object for the vector midpoint (inherits reference from head/tail)

tail_geom property

tail_geom: PointGeometry

Get a point geometry object for the vector tail (start)

add

add(other: Vector) -> Vector

Vector addition originating at LHS (+)

Source code in src/arcpie/utils.py
909
910
911
def add(self, other: Vector) -> Vector:
    """Vector addition originating at LHS (`+`)"""
    return Vector(self.tail, other >> self.head)

angle

angle(other: Vector) -> float

Get the angle in radians between two vectors

Note: When checking angle against a null vector, 0 is returned

Source code in src/arcpie/utils.py
964
965
966
967
968
969
970
971
972
973
974
975
def angle(self, other: Vector) -> float:
    """Get the angle in radians between two vectors

    Note: When checking angle against a null vector, 0 is returned
    """
    if self.is_null or other.is_null:
        return 0

    other = Vector(self.tail, other >> self.tail)
    v = round((self^other)/(self.dist*other.dist), 15)
    ang = round(math.acos(v), 15)        
    return ang

cross

cross(other: Vector) -> Vector

Cross product of two vectors originating at LHS (*)

Source code in src/arcpie/utils.py
923
924
925
926
927
928
929
930
931
932
933
934
935
def cross(self, other: Vector) -> Vector:
    """Cross product of two vectors originating at LHS (`*`)"""
    if self.is_null:
        return self
    if other.is_null: # Null vector at own tail
        return Vector(self.tail, self.tail)
    other = Vector(self.tail, other >> self.tail)
    targ = self.tail_geom.move(
        dx=self.y*other.z - self.z*other.y,
        dy=self.z*other.x - self.x*other.z,
        dz=self.x*other.y - self.y*other.x,
    )
    return Vector(self.tail, targ)

dot

dot(other: Vector) -> float

Dot product of two vectors originating at LHS (@)

Source code in src/arcpie/utils.py
956
957
958
959
def dot(self, other: Vector) -> float:
    """Dot product of two vectors originating at LHS (`@`)"""
    other = Vector(self.tail, other >> self.tail)
    return self.x*other.x + self.y*other.y + self.z*other.z

norm

norm() -> Vector

Normal vector originating at tail

Source code in src/arcpie/utils.py
902
903
904
def norm(self) -> Vector:
    """Normal vector originating at tail"""
    return Vector(self.tail, self.translate(self.tail, 1))

reverse

reverse() -> Vector

Reversed vector

Source code in src/arcpie/utils.py
895
896
897
def reverse(self) -> Vector:
    """Reversed vector"""
    return Vector(self.head, self.tail)

scale

scale(scale: Scalar) -> Vector

Scalar multiplication of vector (*)

Note: Scaling by Zero will return a null vector located at the vector tail

Source code in src/arcpie/utils.py
937
938
939
940
941
942
943
944
def scale(self, scale: Scalar) -> Vector:
    """Scalar multiplication of vector (`*`)

    Note: Scaling by Zero will return a null vector located at the vector tail
    """
    if scale == 0: # Special case for creating a spatially aware null vector
        return Vector(self.tail, self.tail)
    return Vector(self.tail, self.translate(self.tail, scale*self.dist))

subtract

subtract(other: Vector) -> Vector

Vector subtraction originating at LHS (-)

Source code in src/arcpie/utils.py
916
917
918
def subtract(self, other: Vector) -> Vector:
    """Vector subtraction originating at LHS (`-`)"""
    return self + (-other)

translate

translate(point: Point, mag: float | None = None) -> Point
translate(
    point: PointGeometry, mag: float | None = None
) -> PointGeometry
translate(point: Any, mag: float | None = None) -> Any

Translate the provided point along the vector direction. >>

The Point will be moved from its original location along the vector angle the provided distance. The location of the Vector object is not taken into account, only angle and magnitude

PARAMETER DESCRIPTION

point

The point to translate along the given vector

TYPE: Any

mag

The distance to translate the point (default: self.mag)

TYPE: float | None DEFAULT: None

RETURNS DESCRIPTION
Any

A translated Point/PointGeometry

Example
    trans_point = vec >> point
Source code in src/arcpie/utils.py
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
def translate(self, point: Any, mag: float | None = None) -> Any:
    """Translate the provided point along the vector direction. `>>`

    The Point will be moved from its original location along the vector angle the provided distance.
    The location of the `Vector` object is not taken into account, only angle and magnitude

    Args:
        point: The point to translate along the given vector
        mag: The distance to translate the point (default: `self.mag`)

    Returns:
        A translated Point/PointGeometry

    Example:
        ```python
            trans_point = vec >> point
        ```
    """

    if self.is_null or mag == 0:
        return point

    if not isinstance(point, (Point, PointGeometry)):
        raise TypeError(f'point must be Point or PointGeometry not {type(point)}')

    target = point
    ref = None

    if isinstance(target, PointGeometry):
        ref = target.spatialReference
        target = target.centroid

    mag = mag or self.dist
    target = Point(
        target.X + (mag*self.x)/self.dist, 
        target.Y + (mag*self.y)/self.dist, 
        target.Z + (mag*self.z)/self.dist if target.Z is not None else None, 
        target.M, 
        target.ID,
    )

    return (
        PointGeometry(
            target, 
            ref, 
            has_z = target.Z is not None, 
            has_m = target.M is not None, 
            has_id = bool(target.ID), 
        ) 
        if isinstance(point, PointGeometry)
        else target
    )

box_on_point

box_on_point(
    center: Point | PointGeometry,
    width: float,
    height: float,
    angle: float = 0.0,
    ref: SpatialReference | None = None,
    start: Literal["tl", "tr", "bl", "br"] = "tl",
) -> Polygon

Build a rectangular box on a point

PARAMETER DESCRIPTION

center

The center point of the box

TYPE: Point | PointGeometry

width

The width of the box

TYPE: float

height

The height of the box

TYPE: float

angle

An angle to roatate the box by in radians (default: 0.0)

TYPE: float DEFAULT: 0.0

ref

An optional spatial reference to apply to the output polygon

TYPE: SpatialReference | None DEFAULT: None

start

The corner of the box that should be the start point (default: 'tl')

TYPE: Literal['tl', 'tr', 'bl', 'br'] DEFAULT: 'tl'

RETURNS DESCRIPTION
Polygon

A rectangular polygon (with provided ref or ref inhereted from center)

Source code in src/arcpie/utils.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
def box_on_point(
    center: Point | PointGeometry, 
    width: float, height: float, 
    angle: float=0.0, 
    ref: SpatialReference|None=None, 
    start: Literal['tl', 'tr', 'bl', 'br']='tl'
    ) -> Polygon:
    """Build a rectangular box on a point

    Args:
        center (Point|PointGeometry): The center point of the box
        width (float): The width of the box
        height (float): The height of the box
        angle (float): An angle to roatate the box by in radians (default: 0.0)
        ref (SpatialReference|None): An optional spatial reference to apply to the output polygon
        start (Literal['tl', 'tr', 'bl', 'br']): The corner of the box that should be the start point (default: 'tl')

    Returns:
        (Polygon): A rectangular polygon (with provided ref or ref inhereted from center)
    """
    if isinstance(center, PointGeometry):
        ref = ref or center.spatialReference
        if center.spatialReference != ref:
            center = center.projectAs(ref)
        center = center.centroid

    h_width = width/2
    h_height = height/2
    tl = Point(center.X-h_width, center.Y+h_height)
    tr = Point(center.X+h_width, center.Y+h_height)
    bl = Point(center.X-h_width, center.Y-h_height)
    br = Point(center.X+h_width, center.Y-h_height)
    points = deque([tl, tr, br, bl])

    if start == 'tr':
        points.rotate(-1)
    elif start == 'br':
        points.rotate(-2)
    elif start == 'bl':
        points.rotate(-3)

    box = Polygon(Array(points), spatial_reference=ref)
    if angle:
        box = box.rotate(center, angle) # type: ignore
        assert isinstance(box, Polygon)
    return box

center_circle

center_circle(
    center: Point | PointGeometry,
    radius: float,
    ref: SpatialReference | None = None,
) -> Polyline

Create a circle using a center point and a radius

PARAMETER DESCRIPTION

center

The center of the circle

TYPE: Point | PointGeometry

radius

(float): The dist

TYPE: float

ref

(SpatialReference|None): The SpatialReference to use with the returned geometry

TYPE: SpatialReference | None DEFAULT: None

RETURNS DESCRIPTION
Polyline

A Circular Polyline

Note

If a PointGeometry are provided, it will be projected as the provided ref If no ref is provided, the shape will inherit the reference of the center

Reference resolution is as follows: ref -> center.spatialReference

If no center reference can be found and no ref is provided, the returned geometry will have no reference

Source code in src/arcpie/utils.py
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
def center_circle(center: Point|PointGeometry, radius: float, ref: SpatialReference|None=None) -> Polyline:
    """Create a circle using a center point and a radius

    Args:
        center (Point|PointGeometry): The center of the circle
        radius: (float): The dist
        ref: (SpatialReference|None): The SpatialReference to use with the returned geometry

    Returns:
        (Polyline): A Circular Polyline

    Note:
        If a PointGeometry are provided, it will be projected as the provided ref
        If no ref is provided, the shape will inherit the reference of the center

        Reference resolution is as follows:
        `ref -> center.spatialReference`

        If no center reference can be found and no ref is provided, the returned geometry will 
        have no reference
    """
    if isinstance(center, PointGeometry):
        ref = ref or center.spatialReference
        center = center.centroid 
    return two_point_circle(center, Point(center.X, center.Y+radius), ref)

convert_schema

convert_schema(
    schema: Dataset[Any] | Path | str,
    to: Literal[
        "JSON", "XLSX", "HTML", "PDF", "XML", "DYNAMIC_HTML"
    ] = "JSON",
) -> BytesIO

Convert a Schema from one format to another

PARAMETER DESCRIPTION

schema

Path to the schemafile or Dataset to convert

TYPE: Dataset | Path | str

to

Target format (default: 'JSON')

TYPE: Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML', 'DYNAMIC_HTML'] DEFAULT: 'JSON'

YIELDS DESCRIPTION
bytes

Raw bytes object containing the schema file

TYPE:: BytesIO

Source code in src/arcpie/utils.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def convert_schema(schema: Dataset[Any]|Path|str, to: Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML', 'DYNAMIC_HTML']='JSON') -> BytesIO:
    """Convert a Schema from one format to another

    Args:
        schema (Dataset|Path|str): Path to the schemafile or Dataset to convert
        to (Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML', 'DYNAMIC_HTML']): Target format (default: 'JSON')

    Yields:
        bytes: Raw bytes object containing the schema file
    """
    with TemporaryDirectory(suffix=to) as temp:
        temp = Path(temp)
        if not isinstance(schema, (Path, str)):
            # Convert Dataset to report
            schema, = GenerateSchemaReport(str(schema.conn), str(temp), 'json_schema', 'JSON')
        schema = Path(schema)
        conversion, = ConvertSchemaReport(str(schema), str(temp), 'out', to)
        return BytesIO(Path(conversion).read_bytes())

export_project_lyrx

export_project_lyrx(
    project: Project,
    out_dir: Path,
    *,
    indent: int = 4,
    sort: bool = False,
    skip_empty: bool = True,
) -> None

Pull all layers from a project file and output them in a directory as lyrx files

PARAMETER DESCRIPTION

project

The arcpie.Project instance to export

TYPE: Project

out_dir

The target directory for the layer files

TYPE: Path | str

indent

Indentation level of the ouput files (default: 4)

TYPE: int DEFAULT: 4

sort

Sort the output file by key name (default: False)

TYPE: bool DEFAULT: False

skip_empty

Skips writing empty lyrx files for layers with no lyrx data (default: True)

TYPE: bool DEFAULT: True

Usage
>>> export_project_lyrx(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
Note

Output structure will match the structure of the project: Map -> Group -> Layer Where each level is a directory. Group Layers will have a directory entry with individual files for each layer they contain, as well as a single layerfile that contains all their child layers.

Source code in src/arcpie/utils.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def export_project_lyrx(project: Project, out_dir: Path, *, indent: int=4, sort: bool=False, skip_empty: bool=True) -> None:
    """Pull all layers from a project file and output them in a directory as lyrx files

    Args:
        project (Project): The `arcpie.Project` instance to export
        out_dir (Path|str): The target directory for the layer files
        indent (int): Indentation level of the ouput files (default: 4)
        sort (bool): Sort the output file by key name (default: False)
        skip_empty (bool): Skips writing empty lyrx files for layers with no lyrx data (default: True)

    Usage:
        ```python
        >>> export_project_lyrx(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
        ```

    Note:
        Output structure will match the structure of the project:
        `Map -> Group -> Layer`
        Where each level is a directory. Group Layers will have a directory entry with individual
        files for each layer they contain, as well as a single layerfile that contains all their 
        child layers.
    """
    out_dir = Path(out_dir)
    for map in project.maps:
        map_dir = out_dir / map.unique_name
        for layer in map.layers:
            _lyrx = getattr(layer, 'lyrx', None)
            if _lyrx is None:
                print(f'{(layer.cim_dict or {}).get("type")} is invalid!')
                continue
            if skip_empty and not _lyrx:
                continue
            out_file = (map_dir / layer.longName).with_suffix('.lyrx')
            out_file.parent.mkdir(parents=True, exist_ok=True)
            out_file.write_text(json.dumps(_lyrx, indent=indent, sort_keys=sort), encoding='utf-8')

export_project_maps

export_project_maps(
    project: Project,
    out_dir: Path | str,
    *,
    indent: int = 4,
    sort: bool = False,
) -> None

Pull all layers from a project file and output them in a directory as mapx files

PARAMETER DESCRIPTION

project

The arcpie.Project instance to export

TYPE: Project

out_dir

The target directory for the mapx files

TYPE: Path | str

indent

Indentation level of the ouput files (default: 4)

TYPE: int DEFAULT: 4

sort

Sort the output file by key name (default: False)

TYPE: bool DEFAULT: False

Usage
>>> export_project_maps(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
Source code in src/arcpie/utils.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def export_project_maps(project: Project, out_dir: Path|str, *, indent: int=4, sort: bool=False) -> None:
    """Pull all layers from a project file and output them in a directory as mapx files

    Args:
        project (Project): The `arcpie.Project` instance to export
        out_dir (Path|str): The target directory for the mapx files
        indent (int): Indentation level of the ouput files (default: 4)
        sort (bool): Sort the output file by key name (default: False)

    Usage:
        ```python
        >>> export_project_maps(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
        ```
    """
    out_dir = Path(out_dir)
    for map in project.maps:
        map_dir = out_dir / rf'{map.unique_name}'
        out_file = map_dir.with_suffix(f'{map_dir.suffix}.mapx') # handle '.' in map name
        out_file.parent.mkdir(parents=True, exist_ok=True)
        out_file.write_text(json.dumps(map.mapx, indent=indent, sort_keys=sort), encoding='utf-8')

get_subtype_count

get_subtype_count(
    fc: Table | FeatureClass, drop_empty: bool = False
) -> dict[str, int]

Get the subtype counts for a Table or FeatureClass

PARAMETER DESCRIPTION

fc

The Table/FeatureClass you want subtype counts for

TYPE: Table | FeatureClass

drop_empty

Drop any counts that have no features from the output dictionary (default: False)

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
dict[str, int]

A mapping of subtype name to subtype count

Source code in src/arcpie/utils.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def get_subtype_count(fc: Table | FeatureClass, drop_empty: bool=False) -> dict[str, int]:
    """Get the subtype counts for a Table or FeatureClass

    Args:
        fc (Table | FeatureClass): The Table/FeatureClass you want subtype counts for
        drop_empty (bool): Drop any counts that have no features from the output dictionary (default: False)

    Returns:
        (dict[str, int]): A mapping of subtype name to subtype count
    """
    return {
        subtype['Name']: cnt
        for code, subtype in fc.subtypes.items() 
        if fc.subtype_field # has Subtypes
        and (
            (cnt := count(fc[where(f'{fc.subtype_field} = {code}')])) # Get count
            or drop_empty # Drop Empty counts?
        )
    }

get_subtype_counts

get_subtype_counts(
    gdb: Dataset, *, drop_empty: bool = False
) -> dict[str, dict[str, int]]

Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

PARAMETER DESCRIPTION

gdb

The Dataset instance to get subtype counts for

TYPE: Dataset

drop_empty

Drop any counts that have no features from the output dictionary (default: False)

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
dict[str, dict[str, int]]

A mapping of FeatureClass -> SubtypeName -> SubtypeCount

Usage
>>> get_subtype_counts(Dataset('<path/to/gdb>', drop_empty=True))
{
    'FC1': 
        {
            'Default': 10
            'Subtype 1': 6
            ...
        },
    ...
}
Source code in src/arcpie/utils.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def get_subtype_counts(gdb: Dataset, *, drop_empty: bool=False) -> dict[str, dict[str, int]]:
    """Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

    Args:
        gdb (Dataset): The Dataset instance to get subtype counts for
        drop_empty (bool): Drop any counts that have no features from the output dictionary (default: False)

    Returns:
        (dict[str, dict[str, int]]): A mapping of FeatureClass -> SubtypeName -> SubtypeCount

    Usage:
        ```python
        >>> get_subtype_counts(Dataset('<path/to/gdb>', drop_empty=True))
        {
            'FC1': 
                {
                    'Default': 10
                    'Subtype 1': 6
                    ...
                },
            ...
        }
        ```  
    """
    feats: list[Table] = [*gdb.feature_classes.values(), *gdb.tables.values()]
    return {
        fc.name: counts
        for fc in feats
        if (counts := get_subtype_count(fc))
        or not drop_empty
    }

iter_parts

iter_parts(
    line: Polyline, start: bool = True, end: bool = True
) -> Iterator[Iterator[PointGeometry]]

Get a part iterator for a Polyline

PARAMETER DESCRIPTION

line

The Polyline to iterate parts for

TYPE: Polyline

start

Include the line startpoint (default: True)

TYPE: bool DEFAULT: True

end

Include the line endpoint (default: True)

TYPE: bool DEFAULT: True

YIELDS DESCRIPTION
Iterator[PointGeometry]

Iterators of part PointGeometries for all parts in the line

Source code in src/arcpie/utils.py
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
def iter_parts(line: Polyline, start: bool = True, end: bool = True) -> Iterator[Iterator[PointGeometry]]:
    """Get a part iterator for a Polyline

    Args:
        line: The Polyline to iterate parts for
        start: Include the line startpoint (default: `True`)
        end: Include the line endpoint (default: `True`)

    Yields:
        Iterators of part PointGeometries for all parts in the line
    """
    yield from (
        iter_points(Polyline(part, line.spatialReference, start, end)) 
        for part in line
    )

iter_points

iter_points(
    line: Polyline, start: bool = True, end: bool = True
) -> Iterator[PointGeometry]

Get a point iterator for a Polyline

PARAMETER DESCRIPTION

line

The Polyline to iterate points for

TYPE: Polyline

start

Include the line startpoint (default: True)

TYPE: bool DEFAULT: True

end

Include the line endpoint (default: True)

TYPE: bool DEFAULT: True

Yields: PointGeometries for all points in the line

Source code in src/arcpie/utils.py
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
def iter_points(line: Polyline, start: bool = True, end: bool = True) -> Iterator[PointGeometry]:
    """Get a point iterator for a Polyline

    Args:
        line: The Polyline to iterate points for
        start: Include the line startpoint (default: `True`)
        end: Include the line endpoint (default: `True`)
    Yields:
        PointGeometries for all points in the line
    """
    section = slice(0 if start else 1, None if end else -1)
    yield from (
        PointGeometry(point, line.spatialReference)
        for part in line
        for point in list[Point](part)[section] # type: ignore
    )

nat

nat(val: str) -> tuple[tuple[int, ...], tuple[str, ...]]

Natural sort key for use in string sorting

PARAMETER DESCRIPTION

val

A value that you want the natural sort key for

TYPE: str

RETURNS DESCRIPTION
tuple[tuple[int, ...], tuple[str, ...]

A tuple containing all numeric and

tuple[str, ...]

string components in order of appearance. Best used as a sort key

Usage
>>> pages = ['P-1.3', 'P-2.11', ...]
>>> pages.sort(key=nat)
>>> print(pages)
['P-1.1', 'P-1.2', ...]
Source code in src/arcpie/utils.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def nat(val: str) -> tuple[tuple[int, ...], tuple[str, ...]]:
    """Natural sort key for use in string sorting

    Args:
        val (str): A value that you want the natural sort key for

    Returns:
        (tuple[tuple[int, ...], tuple[str, ...]): A tuple containing all numeric and 
        string components in order of appearance. Best used as a sort key

    Usage:
        ```python
        >>> pages = ['P-1.3', 'P-2.11', ...]
        >>> pages.sort(key=nat)
        >>> print(pages)
        ['P-1.1', 'P-1.2', ...]
        ```
    """
    _digits: list[int] = []
    _alpha: list[str] = []
    _digit_chars: list[str] = []
    for s in val:
       if s.isdigit():
          _digit_chars.append(s)
       else:
          _alpha.append(s)
          if _digit_chars:
             _digits.append(int(''.join(_digit_chars)))
             _digit_chars.clear()
    if _digit_chars:
       _digits.append(int(''.join(_digit_chars)))
    return tuple(_digits), tuple(_alpha)

patch_schema_rules

patch_schema_rules(
    schema: SchemaWorkspace | Path | str,
    *,
    remove_rules: bool = False,
) -> SchemaWorkspace

Patch an exported Schema doc by re-linking attribute rules to table names

PARAMETER DESCRIPTION

schema

The input schema to patch

TYPE: Path | str

remove_rules

Remove attribute rules from the schema (default: False)

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
SchemaWorkspace

A patched schema dictionary

TYPE: SchemaWorkspace

Source code in src/arcpie/utils.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def patch_schema_rules(schema: SchemaWorkspace|Path|str, 
                       *,
                       remove_rules: bool=False) -> SchemaWorkspace:
    """Patch an exported Schema doc by re-linking attribute rules to table names

    Args:
        schema (Path|str): The input schema to patch
        remove_rules (bool): Remove attribute rules from the schema (default: False)

    Returns:
        SchemaWorkspace: A patched schema dictionary
    """
    # Load schema
    if isinstance(schema, dict):
        workspace = schema
    else:
        schema = Path(schema)
        if not schema.suffix == '.json':
            raise ValueError(f'Schema Patching can only be done on json schemas!, got {schema.suffix}')
        workspace: SchemaWorkspace = json.load(schema.open(encoding='utf-8'))

    # Get all root Features
    features = [
        ds 
        for ds in workspace['datasets'] 
        if 'datasets' not in ds
    ]
    # Get all features that live in a FeatureDataset
    # Use listcomp since typing is weird
    [
        features.extend(ds['datasets']) 
        for ds in workspace['datasets'] 
        if 'datasets' in ds
    ]
    # Get a translation dictionary of catalogID -> name
    guid_to_name = {
        fc['catalogID']: fc['name'] 
        for fc in features 
        if 'catalogID' in fc
    }
    for feature in filter(lambda fc: 'attributeRules' in fc, features):
        feature: SchemaDataset
        if remove_rules:
            feature['attributeRules'] = []
            continue
        rules = feature['attributeRules']
        for rule in rules:
            script = rule['scriptExpression']
            for guid, name in guid_to_name.items():
                script = script.replace(guid, name)
            rule['scriptExpression'] = script
    return workspace

print

print(
    *values: object,
    sep: str = " ",
    end: str = "\n",
    file: Any = None,
    flush: bool = False,
    severity: Literal["INFO", "WARNING", "ERROR"]
    | None = None,
) -> None

Print a message to the ArcGIS Pro message queue and stdout set severity to 'WARNING' or 'ERROR' to print to the ArcGIS Pro message queue with the appropriate severity

Source code in src/arcpie/utils.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def print(*values: object,
          sep: str = " ",
          end: str = "\n",
          file: Any = None,
          flush: bool = False,
          severity: Literal['INFO', 'WARNING', 'ERROR']|None = None) -> None:
    """ Print a message to the ArcGIS Pro message queue and stdout
    set severity to 'WARNING' or 'ERROR' to print to the ArcGIS Pro message queue with the appropriate severity
    """

    # Print the message to stdout
    builtins.print(*values, sep=sep, end=end, file=file, flush=flush)

    end = "" if end == '\n' else end
    message = f"{sep.join(map(str, values))}{end}"
    # Print the message to the ArcGIS Pro message queue with the appropriate severity
    match severity:
        case "WARNING":
            AddWarning(f"{message}")
        case "ERROR":
            AddError(f"{message}")
        case _:
            AddMessage(f"{message}")
    return

shortest_path

shortest_path(
    source: PointLike,
    target: PointLike,
    network: LineCollection,
    *,
    all_paths: Literal[False] = False,
    method: Literal[
        "dijkstra", "bellman-ford"
    ] = "dijkstra",
    weighted: bool = True,
    precision: int = 6,
) -> Polyline | None
shortest_path(
    source: PointLike,
    target: PointLike,
    network: LineCollection,
    *,
    all_paths: Literal[True] = True,
    method: Literal[
        "dijkstra", "bellman-ford"
    ] = "dijkstra",
    weighted: bool = True,
    precision: int = 6,
) -> list[Polyline] | None
shortest_path(
    source: PointLike,
    target: PointLike,
    network: LineCollection,
    *,
    all_paths: bool = False,
    method: Literal[
        "dijkstra", "bellman-ford"
    ] = "dijkstra",
    weighted: bool = True,
    precision: int = 6,
) -> Polyline | list[Polyline] | None

Find the shortest path or paths given a source point, target point and network of Polylines

PARAMETER DESCRIPTION

source

The start point for the path

TYPE: PointGeometry | Point

target

The end point for the path

TYPE: PointGeometry | Point

network

The polylines to traverse

TYPE: FeatureClass[Polyline, Any] | Sequence[Polyline] | Iterator[Polyline]

all_paths

If True, yield all shortest paths from (default: False)

TYPE: bool DEFAULT: False

method

The graph traversal algorithm to use (default: 'dijkstra')

TYPE: Literal['dijkstra', 'bellman-ford'] DEFAULT: 'dijkstra'

weighted

Use line lengths to weight the paths (default: True)

TYPE: bool DEFAULT: True

precision

Number of decimal places to round coordinates to (default: 6)

TYPE: int DEFAULT: 6

RETURNS DESCRIPTION
Polyline | None

The unioned polyline of the path or None if no path is found

YIELDS DESCRIPTION
Polyline

Yields all shortest paths if all_paths is set (None is still returned if no path found)

RAISES DESCRIPTION
ValueError

When input arguments are not of the correct types (PointLike, PointLike, LineCollection)

Example
path = shortest_path(p1, p2, line_features)
paths = shortest_path(p1, p2, line_features, all_paths=True)

if path is None:
    print('No Path')
else:
    print(path.length)

# Check that path(s) were found
if paths is None:
    print('No Path')
else:
    for p in paths:
        print(p.length)
Source code in src/arcpie/utils.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
def shortest_path(
    source: PointLike, 
    target: PointLike, 
    network: LineCollection,
    *,
    all_paths: bool=False,
    method: Literal['dijkstra', 'bellman-ford']='dijkstra',
    weighted: bool=True,
    precision: int=6,
    ) -> Polyline | list[Polyline] | None:
    """Find the shortest path or paths given a source point, target point and network of Polylines

    Args:
        source (PointGeometry | Point): The start point for the path
        target (PointGeometry | Point): The end point for the path
        network (FeatureClass[Polyline, Any] | Sequence[Polyline] | Iterator[Polyline]): The polylines to traverse
        all_paths (bool): If True, yield all shortest paths from (default: False)
        method (Literal['dijkstra', 'bellman-ford']): The graph traversal algorithm to use (default: 'dijkstra')
        weighted (bool): Use line lengths to weight the paths (default: True)
        precision (int): Number of decimal places to round coordinates to (default: 6)

    Returns:
        (Polyline | None): The unioned polyline of the path or None if no path is found

    Yields:
        (Polyline): Yields all shortest paths if `all_paths` is set (None is still *returned* if no path found)

    Raises:
        (ValueError): When input arguments are not of the correct types (`PointLike`, `PointLike`, `LineCollection`)

    Example:
        ```python
        path = shortest_path(p1, p2, line_features)
        paths = shortest_path(p1, p2, line_features, all_paths=True)

        if path is None:
            print('No Path')
        else:
            print(path.length)

        # Check that path(s) were found
        if paths is None:
            print('No Path')
        else:
            for p in paths:
                print(p.length)
        ```   
    """

    # Parameter Validations
    if isinstance(network, FeatureClass):
        if network.describe.shapeType != 'Polyline':
            raise ValueError(f'network must have polyline geometry')
        network = list(network.shapes)

    if not isinstance(network, Sequence):
        network = list(network)

    if not network:
        return None

    if not isinstance(network[0], Polyline): # type: ignore
        raise ValueError(f'network must have polyline geometry')

    # Project point geometries to match reference of network, extract X,Y tuples
    if isinstance(source, PointGeometry):
        if source.isMultipart:
            raise ValueError('source point must not be multipart')
        source = source.projectAs(network[0].spatialReference).centroid
    _source = (round(source.X, precision), round(source.Y, precision))

    if isinstance(target, PointGeometry):
        if target.isMultipart:
            raise ValueError('target point must not be multipart')
        target = target.projectAs(network[0].spatialReference).centroid
    _target = (round(target.X, precision), round(target.Y, precision))

    G = Graph(
        [
            (
                (round(l.firstPoint.X, precision), round(l.firstPoint.Y, precision)), 
                (round(l.lastPoint.X, precision), round(l.lastPoint.Y, precision)), 
                {'shape': l, 'length': l.length}
            ) 
            for l in network
        ]
    )
    try:
        if all_paths:
            paths = nx_all_shortest_paths(G, _source, _target, weight='length' if weighted else None, method=method)
        else:
            paths = nx_shortest_path(G, _source, _target, weight='length' if weighted else None, method=method)
    except (NodeNotFound, NetworkXNoPath):
        return None

    if all_paths:
        _paths: list[Polyline] = []
        for path in paths:
            assert isinstance(path, list)
            edges: list[Polyline] = [G.get_edge_data(u, v)['shape'] for u, v in zip(path, path[1:])]
            _paths.append(reduce(lambda acc, s: acc.union(s), edges))  # pyright: ignore[reportArgumentType]
        return _paths

    else:
        assert isinstance(paths, list)
        edges: list[Polyline] = [G.get_edge_data(u, v)['shape'] for u, v in zip(paths, paths[1:])]
        if edges:
            return reduce(lambda acc, s: acc.union(s), edges) # pyright: ignore[reportReturnType]

split_at_points

split_at_points(
    lines: FeatureClass[Polyline, Any],
    points: FeatureClass[PointGeometry, Any],
    *,
    buffer: float = 0.0,
    min_len: float = 0.0,
) -> Iterator[tuple[int, Polyline]]

Split lines at provided points

PARAMETER DESCRIPTION

lines

Line features to split

TYPE: FeatureClass[Polyline]

points

Points to split on

TYPE: FeatureClass[PointGeometry]

buffer

Split buffer in feature units (default: 0.0 [exact])

TYPE: float DEFAULT: 0.0

min_len

Minumum length for a new line in feature units (default: 0.0)

TYPE: float DEFAULT: 0.0

YIELDS DESCRIPTION
tuple[int, Polyline]]

Tuples of parent OID and child shape

Warning

When splitting features in differing projections, the point features will be projected into the spatial reference of the line features.

Example
>>> # Simple process for splitting lines in place
... 
>>> # Initialize a set to capture the removed ids
>>> removed: set[int] = set()
>>> with lines.editor:
...     # Insert new lines
...     with lines.insert_cursor('SHAPE@') as cur:
...         for parent, new_line in split_at_points(lines, points):
...             cur.insertRow([new_line])
...             removed.add(parent) # Add parent ID to removed
...     # Remove old lines (if you're inserting to the same featureclass)
...     with lines.update_cursor('OID@') as cur:
...         for _ in filter(lambda r: r[0] in removed, cur):
...             cur.deleteRow() 
Source code in src/arcpie/utils.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def split_at_points(lines: FeatureClass[Polyline, Any], points: FeatureClass[PointGeometry, Any], 
                *, 
                buffer: float=0.0,
                min_len: float=0.0) -> Iterator[tuple[int, Polyline]]:
    """Split lines at provided points

    Args:
        lines (FeatureClass[Polyline]): Line features to split
        points (FeatureClass[PointGeometry]): Points to split on
        buffer (float): Split buffer in feature units (default: 0.0 [exact])
        min_len (float): Minumum length for a new line in feature units (default: 0.0)

    Yields:
        ( tuple[int, Polyline]] ): Tuples of parent OID and child shape

    Warning:
        When splitting features in differing projections, the point features will be projected
        into the spatial reference of the line features.

    Example:
        ```python
        >>> # Simple process for splitting lines in place
        ... 
        >>> # Initialize a set to capture the removed ids
        >>> removed: set[int] = set()
        >>> with lines.editor:
        ...     # Insert new lines
        ...     with lines.insert_cursor('SHAPE@') as cur:
        ...         for parent, new_line in split_at_points(lines, points):
        ...             cur.insertRow([new_line])
        ...             removed.add(parent) # Add parent ID to removed
        ...     # Remove old lines (if you're inserting to the same featureclass)
        ...     with lines.update_cursor('OID@') as cur:
        ...         for _ in filter(lambda r: r[0] in removed, cur):
        ...             cur.deleteRow() 
        ```
    """
    line_iter: Iterator[tuple[Polyline, int]] = lines[('SHAPE@', 'OID@')]
    for line, oid in line_iter:
        int_points: list[PointGeometry] = []
        with points.reference_as(line.spatialReference), points.fields_as('SHAPE@'):
            int_points = [r['SHAPE@'] for r in points[line.buffer(buffer)]]

        if len(int_points) == 0 or all(p.touches(line) for p in int_points):
            continue

        prev_measure = 0.0
        measures = sorted(line.measureOnLine(p) for p in int_points) + [line.length]
        for measure in measures:
            seg = line.segmentAlongLine(prev_measure, measure)
            prev_measure = measure
            if seg and seg.length >= (min_len or 0):
                yield oid, seg

split_lines_at_points

split_lines_at_points(
    lines: Polyline
    | Sequence[Polyline]
    | Iterator[Polyline],
    points: Sequence[PointGeometry]
    | Iterator[PointGeometry],
) -> Iterator[Polyline]

Split a Polyline or Sequence/Iterable of polylines at provided points

PARAMETER DESCRIPTION

lines

The line or lines to split

TYPE: Polyline | Sequence[Polyline] | Iterator[Polyline]

points

The points to split at

TYPE: Sequence[PointGeometry] | Iterator[PointGeometry]

YIELDS DESCRIPTION
Polyline

Segments of the polyline split at the input points

Source code in src/arcpie/utils.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
def split_lines_at_points(lines: Polyline | Sequence[Polyline] | Iterator[Polyline], points: Sequence[PointGeometry] | Iterator[PointGeometry]) -> Iterator[Polyline]:
    """Split a Polyline or Sequence/Iterable of polylines at provided points

    Args:
        lines (Polyline | Sequence[Polyline] | Iterator[Polyline]): The line or lines to split
        points (Sequence[PointGeometry] | Iterator[PointGeometry]): The points to split at

    Yields:
        (Polyline): Segments of the polyline split at the input points
    """
    if isinstance(lines, Polyline):
        lines = [lines]
    if not isinstance(points, list):
        points = list(points)

    for line in lines:
        int_points = [p for p in points if not p.disjoint(line)]
        if not int_points:
            yield line
            continue
        if all(p.touches(line) for p in int_points):
            yield line
            continue
        prev_measure = 0.0
        measures = sorted(line.measureOnLine(p) for p in int_points)
        for measure in measures + [line.length]:
            if prev_measure == measure:
                continue
            yield line.segmentAlongLine(prev_measure, measure).projectAs(line.spatialReference)
            prev_measure = measure

two_point_circle

two_point_circle(
    center: Point | PointGeometry,
    end: Point | PointGeometry,
    ref: SpatialReference | None = None,
) -> Polyline

Create a circle using a center point and an end point

PARAMETER DESCRIPTION

center

The center of the circle

TYPE: Point | PointGeometry

end

(Point|PointGeometry): The end point of the arc (distance from center is Circle radius)

TYPE: Point | PointGeometry

ref

(SpatialReference|None): The SpatialReference to use with the returned geometry

TYPE: SpatialReference | None DEFAULT: None

RETURNS DESCRIPTION
Polyline

A Circular Polyline

Note

If PointGeometries are provided, they will be projected as the provided ref If no ref is provided, the shape will inherit the reference of the center

Reference resolution is as follows: ref -> center.spatialReference -> end.spatialReference

If both points are Point objects with no spatial reference, and no ref is provided, The returned Polyline will have no spatial reference

Source code in src/arcpie/utils.py
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def two_point_circle(center: Point|PointGeometry, end: Point|PointGeometry, ref: SpatialReference|None=None) -> Polyline:
    """Create a circle using a center point and an end point

    Args:
        center (Point|PointGeometry): The center of the circle
        end: (Point|PointGeometry): The end point of the arc (distance from center is Circle radius)
        ref: (SpatialReference|None): The SpatialReference to use with the returned geometry

    Returns:
        (Polyline): A Circular Polyline

    Note:
        If PointGeometries are provided, they will be projected as the provided ref
        If no ref is provided, the shape will inherit the reference of the center

        Reference resolution is as follows:
        `ref -> center.spatialReference -> end.spatialReference`

        If both points are Point objects with no spatial reference, and no ref is provided, 
        The returned Polyline will have no spatial reference
    """
    if isinstance(center, PointGeometry):
        _c_ref = center.spatialReference
        if ref and _c_ref != ref:
            center = center.projectAs(ref)
        else:
            ref = ref or _c_ref
        center = center.centroid
    if isinstance(end, PointGeometry):
        _e_ref = end.spatialReference
        if ref and _e_ref != ref:
            end = end.projectAs(ref)
        else:
            ref = ref or _e_ref
        end = end.centroid

    esri_json: dict[str, Any] = {}
    _arc: dict[str, Any] = {'a': [[end.X, end.Y, end.Z, end.M], [center.X, center.Y], 0, 1]}
    esri_json['curvePaths'] = [[[end.X, end.Y, end.Z], _arc]]
    if ref:
        esri_json['spatialReference'] = {'wkid': ref.factoryCode, 'latestWkid': ref.factoryCode}
    return AsShape(esri_json, esri_json=True) # type: ignore

vector_at

vector_at(
    line: Polyline,
    point: PointGeometry | Point,
    *,
    delta: float = 0.01,
    snap: bool = False,
) -> Vector

Get the vector of the line at the given point (p-delta -> p+delta)

PARAMETER DESCRIPTION

line

The line to get a Vector for

TYPE: Polyline

point

The PointGeometry specifying the vector location (projects to line reference)

TYPE: PointGeometry | Point

delta

The distance (meters) to traverse the line in both directions (default: 0.01)

TYPE: float DEFAULT: 0.01

snap

If the input point is disjoint from the line, snap it to the line (default: 'False')

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
Vector

A Vector of length delta*2 that describes the slope of the line at the provided point

Source code in src/arcpie/utils.py
724
725
726
727
728
729
730
731
732
733
734
735
736
737
def vector_at(line: Polyline, point: PointGeometry | Point, *, delta: float = 0.01, snap: bool = False) -> Vector:
    """Get the vector of the line at the given point (p-delta -> p+delta)

    Args:
        line: The line to get a Vector for
        point: The PointGeometry specifying the vector location (projects to line reference)
        delta: The distance (meters) to traverse the line in both directions (default: `0.01`)
        snap: If the input point is disjoint from the line, snap it to the line (default: 'False')

    Returns:
        A Vector of length delta*2 that describes the slope of the line at the provided point
    """
    v1, v2 = vectors_at(line, point, delta=delta, snap=snap)
    return v1 + v2

vectors_at

vectors_at(
    line: Polyline,
    point: PointGeometry | Point,
    *,
    delta: float = 0.01,
    snap: bool = False,
) -> tuple[Vector, Vector]

Get the vector of the line at the given point (p-delta -> p+delta)

PARAMETER DESCRIPTION

line

The line to get a Vector for

TYPE: Polyline

point

The PointGeometry specifying the vector location (projects to line reference)

TYPE: PointGeometry | Point

delta

The distance (meters) to traverse the line (default: 0.01)

TYPE: float DEFAULT: 0.01

snap

If the input point is disjoint from the line, snap it to the line (default: 'False')

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
tuple[Vector, Vector]

A tuple of Vectors representing the bearing to start and end at distance delta from point

Note

If the in point is a start/end point on the line, one of the Vectors will be a null vector!

Source code in src/arcpie/utils.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
def vectors_at(line: Polyline, point: PointGeometry | Point, *, delta: float = 0.01, snap: bool = False) -> tuple[Vector, Vector]:
    """Get the vector of the line at the given point (p-delta -> p+delta)

    Args:
        line: The line to get a Vector for
        point: The PointGeometry specifying the vector location (projects to line reference)
        delta: The distance (meters) to traverse the line (default: `0.01`)
        snap: If the input point is disjoint from the line, snap it to the line (default: 'False')

    Returns:
        A tuple of Vectors representing the bearing to start and end at distance delta from point

    Raises:
        ValueError if the point is disjoint from the line and `snap` is unset

    Note:
        If the in point is a start/end point on the line, one of the Vectors will be a null vector!
    """
    ref = line.spatialReference
    mpu = ref.metersPerUnit
    if isinstance(point, Point): 
        point = PointGeometry(point, ref)
    else: 
        point.projectAs(ref)

    if line.disjoint(point):
        if snap: 
            point = line.snapToLine(point)
        else: 
            raise ValueError('Vector point must not be disjoint from line (use `snap=True` to snap point to line)')

    pt_meas = line.measureOnLine(point)*mpu

    # Handle firstPoint
    if pt_meas < delta:
        pt_meas = delta
        point = line.positionAlongLine(pt_meas)
    # Handle lastPoint
    elif pt_meas == line.length:
        pt_meas = line.length - delta
        point = line.positionAlongLine(pt_meas)

    plus = line.positionAlongLine((pt_meas + delta)/mpu)
    minus = line.positionAlongLine((pt_meas - delta)/mpu)
    return Vector(point, plus), Vector(point, minus)