Metadata-Version: 2.5
Name: opencloud3d
Version: 2.1.0
Summary: Query hosted point clouds by radius, without downloading them.
Project-URL: Homepage, https://app.opencloud3d.com
Project-URL: Documentation, https://app.opencloud3d.com/docs
Author: OpenCloud3D
License: MIT
Keywords: copc,geospatial,las,laz,lidar,point cloud
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Requires-Dist: laspy<3,>=2.5
Requires-Dist: lazrs<1,>=0.5
Requires-Dist: numpy>=1.21
Description-Content-Type: text/markdown

# opencloud3d

Query hosted point clouds by radius, without downloading them.

```bash
pip install opencloud3d
```

A survey tile is gigabytes. The twenty metres around one pole is kilobytes. This
client asks for the second without moving the first: the clouds are stored as
[COPC](https://copc.io), so the server range-reads only the octree nodes your
radius touches and hands back a LAZ.

## Use it

```python
from opencloud3d import Client

with Client() as oc:                    # reads OPENCLOUD3D_API_KEY
    ds = oc.dataset("053025e6-c73f-4bfc-9066-dcaeeadb6122")

    ds.extent
    # {'epsg': 25832, 'bounds': [...], 'wgs84_bounds': [...], 'point_count': 15787378}

    pts = ds.query(lon=5.8672, lat=50.9784, radius=20)
    len(pts)          # 13394
    pts.xyz           # (13394, 3) numpy array, in the dataset's own CRS
    pts.points        # the full laspy object — intensity, classification, header
    pts.save("neighbourhood.laz")
```

Get a key at [app.opencloud3d.com/api-keys](https://app.opencloud3d.com/api-keys).

## The shape of a query

**Centre.** Either `lon`/`lat` (WGS84, what a map gives you) or `x`/`y` in the
dataset's own CRS (what a survey file gives you) — one pair, never both. There is
no default on purpose: a coordinate read in the wrong frame comes back *empty*,
which looks like "nothing there" rather than like the mistake it is.

**Radius is always metres**, in both spellings, whatever the CRS is measured in.
`radius=20` means twenty metres on a cloud in degrees and on one in US survey
feet, so the number in your script means the same thing on every dataset.

**Cylinder by default, sphere on request.** Leaving `z` out asks for every height
at that spot — which is what "within 20 m of this pole" means. Passing `z` makes
it a true sphere; a sphere centred on a guessed height silently misses the ground
beneath it, so it is opt-in.

```python
ds.query(lon=..., lat=..., radius=20)                    # cylinder, all heights
ds.query(lon=..., lat=..., radius=20, z=95.0)            # sphere about z = 95
ds.query(lon=..., lat=..., radius=20, z_min=90, z_max=100)   # a 10 m band
```

**Classes** are ASPRS codes; `2` is ground.

```python
ground = ds.query(lon=..., lat=..., radius=50, classes=[2])
```

⚠️ This only means anything on a cloud that has been **classified**. Most raw
uploads carry class 0 or 1 for every point, and filtering one of those on `[2]`
returns nothing — correctly, and confusingly. Check the classification histogram
in `ds.detail["stats"]` first.

## An area, not a radius

A radius answers *"what is near this pole"*. Every real delivery is the other
question — *"give me this parcel"* — so `extract` is the complement of `query`,
not a variant of it.

```python
# a rectangle, in WGS84
subset = ds.extract(bbox=(5.870, 50.980, 5.875, 50.985))

# or the shape actually drawn: a corridor, a parcel, a site boundary
corridor = ds.extract(polygon=[(5.870, 50.980), (5.875, 50.981), (5.874, 50.985)])

# and the same class filter
ground = ds.extract(bbox=(...), classes=[2])
```

When you give a `polygon` it is the authority: the server recomputes the
rectangle from it, so the shape and the box can never disagree. PDAL reads only
the octree nodes the area touches, so cutting a hectare out of a 20 GB survey
does not move 20 GB.

Like `query`, this returns **one `PointSet` per file that had points**, because
a rectangle over a ten-tile corridor legitimately crosses three of them and two
files can be in different CRSs — silently concatenating coordinates from two
frames is the kind of wrong answer that looks right.

## Empty answers are not errors

```python
from opencloud3d import NoPointsFound

try:
    pts = ds.query(lon=..., lat=..., radius=5, classes=[2])
except NoPointsFound:
    ...   # the radius fell outside the survey, or nothing matched the filter
```

## From the command line

```bash
export OPENCLOUD3D_API_KEY=oc3d_live_…
opencloud3d datasets
opencloud3d extent 053025e6-c73f-4bfc-9066-dcaeeadb6122
opencloud3d query 053025e6-… --lon 5.8672 --lat 50.9784 --radius 20 -o out.laz
```

## Limits

A query is bounded by how many points it would **return**, not by its radius —
twenty metres of empty countryside and twenty metres of dense urban survey are
three orders of magnitude apart. The ceiling is two million points per query,
because a query has to finish inside an HTTP round trip; a larger area is a job
for `extract`, which is bounded by area rather than by point count. Ask for a smaller radius, or narrow it with
`z_min`/`z_max` or `classes`, if you are told it is too large.

For scale, on a 15.8 M-point airborne tile served from object storage: a 20 m
radius returns ~15 k points in ~4 s, and a 200 m radius ~1.7 M points in ~30 s.
Most of the first four seconds is storage latency rather than your radius.

API access requires a plan that includes it, and each key is rate limited.

Full reference: [app.opencloud3d.com/docs](https://app.opencloud3d.com/docs)
