# Landscaping

A **landscape takeoff** is a takeoff on a job that measures landscaping. There are two kinds, the same two the app has:

- **Aerial** (`"type": "aerial"`): areas and trees measured on aerial imagery. Areas are by class (lawn, mulch beds, hedges, gravel, ponds, decks and more) in square feet; trees, shrubs, stumps and fountains are counted.
- **Plan** (`"type": "blueprint"`): plants counted on a landscape plan PDF. Each plant has a quantity, plant beds and other ground covers are measured as layers, and irrigation is counted from the irrigation sheets.

Everything here runs the app's own logic as the person who created the API key: the same plan gates, the same billing, the same save rules and the same revision history as the takeoff editor. A takeoff you start here opens in the editor (`editor_url`), and a takeoff your team draws there can be read and changed here.

Typical aerial flow:

1. `POST /v1/jobs/{job_id}/landscape-takeoffs` with `{"type": "aerial"}`. Keep the takeoff `id`.
2. `POST /v1/landscape-takeoffs/{id}/detect` with the site outline. Keep the run `id`.
3. Poll `GET /v1/landscape-runs/{run_id}` until `status` is `completed`.
4. `GET /v1/landscape-takeoffs/{id}` for the quantities. Add, reclassify or remove items as needed.

Typical plan flow, the same steps as the plan editor:

1. `POST /v1/jobs/{job_id}/landscape-takeoffs` with `{"type": "blueprint", "file_url": "..."}`, then poll the run it returns for the takeoff `id`.
2. Say where the plant schedule is: `PUT /v1/landscape-takeoffs/{id}/schedule` (or let the first run use the sheet's own).
3. `POST .../find-plants` to dot every plant symbol and callout tag on a sheet, `POST .../group-plants` to sort them into species and name them, `POST .../read-callouts` to read the tags' quantities. Or `POST .../advanced-run` for a close read of a whole sheet.
4. `POST .../irrigation` with `"apply": true` on irrigation sheets.
5. `GET /v1/landscape-takeoffs/{id}` for plants and their quantities, plant areas and irrigation. Check a sheet with `GET .../sheets/{page}/image`.
6. Rename, re-count, merge or remove plants, and fix the plant schedule. Undo any run with `POST /v1/landscape-runs/{run_id}/undo`.

!!! note
    Partner keys add `firm_id` to name the workspace: in the query string, or in the JSON body on writes. See [Partner API](partner-api.md).

## Classes

`GET /v1/landscape/classes`

**Permission:** Read measurements

The area classes and the tree and object classes aerial measuring uses, with their ids. `detected` is `true` for the classes the landscape model finds by itself; the others (road, parking area) are drawn by hand or added through the API.

```json
{
  "data": {
    "areas": [
      {"id": "lawn", "name": "Lawn", "color": "#22d3ee", "unit": "SF", "detected": true},
      {"id": "mulch", "name": "Mulch / Beds", "color": "#7c3aed", "unit": "SF", "detected": true}
    ],
    "objects": [
      {"id": "deciduous", "name": "Deciduous", "color": "#00e676", "unit": "EA", "tree": true, "detected": true},
      {"id": "shrub", "name": "Shrub", "color": "#b2ff59", "unit": "EA", "tree": false, "detected": true}
    ]
  }
}
```

## Takeoffs

### List landscape takeoffs

`GET /v1/landscape-takeoffs`

**Permission:** Read measurements

Most recently changed first. Filters: `job_id`, `type` (`aerial` or `blueprint`), `limit` (default 50, at most 200), `offset`. Each row has `id`, `name`, `type`, `job_id`, `created_at`, `updated_at`; aerial rows add `mode` (`auto` or `manual`), plan rows add `editor` (`plan`, or `standalone` for takeoffs from the older standalone plan tool, which are read only here).

### Start a landscape takeoff

`POST /v1/jobs/{job_id}/landscape-takeoffs`

**Permission:** Write measurements

| Name | Type | Required | Description |
|---|---|---|---|
| `type` | string | Yes | `aerial` or `blueprint`. |
| `name` | string | No | Up to 200 characters. Default "Auto Aerial", "Manual Aerial", or "&lt;job name&gt; Blueprint Takeoff". |
| `manual` | boolean | No | Aerial only. `true` makes a Manual Aerial takeoff: drawing only, no AI measuring. Needs Manual Aerial on the plan. |
| `file_url` | string | Plan: one of the two | A public `https://` link that returns the PDF, up to 100 MB. |
| `file_id` | integer | Plan: one of the two | A PDF already on a job ([Photos & files](photos-and-files.md)). |
| `pages` | array | No | Plan only. Which PDF pages to keep, for example `[1, 3, 4]`. Default all. |

```bash
curl -X POST https://trutec.ai/api/v1/jobs/7012/landscape-takeoffs \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"type": "blueprint", "file_url": "https://example.com/plans/L-101.pdf", "pages": [1, 2]}'
```

An aerial takeoff returns `201` with the takeoff in the shape of [Get a takeoff](#get-a-takeoffs-quantities), its quantities still empty.

A plan takeoff returns `202` with a run: the PDF is fetched and checked right away, then stored one sheet per page the way the plan editor stores it, which can take a few minutes for a big plan set. Poll `GET /v1/landscape-runs/{run_id}`; when it is `completed`, `result.takeoff_id` and `result.takeoff` are the new takeoff. It opens in the plan editor in landscape mode.

**Billing.** Starting an aerial takeoff is free; its first detection uses a credit (see [Measure an aerial takeoff](#measure-an-aerial-takeoff)). A plan takeoff uses one takeoff credit once its pages are stored, as starting an AI Blueprint takeoff does in TruTec. A bad link, a file that isn't a readable PDF, or a PDF that can't be stored costs nothing. In a workspace on landscape.trutec.ai, plan takeoffs are free, as they are in that app. Plan takeoffs need Blueprint takeoffs on the plan (Gold and above).

| Status | `error` |
|---|---|
| 400 | `type must be "aerial" or "blueprint"`, `Send file_url or file_id`, `Page 9 is not in this PDF`, `Unknown fields` |
| 402 | `No takeoff credits remaining`, an inactive subscription, `Manual Aerial is available on Gold and above.` |
| 403 | `API key lacks measurement write permission`, `Blueprint Takeoffs require a Gold plan or above.` |
| 404 | `Job not found`, `File not found` |
| 422 | `Could not fetch file_url`, `PDF too large`, `Not a readable PDF` (not a PDF, or password protected) |
| 429 | `Too many runs`: the workspace already has 3 landscape runs going |

### Get a takeoff's quantities

`GET /v1/landscape-takeoffs/{takeoff_id}`

**Permission:** Read measurements

`unit` (`sf`, `sy`, `sm` or `acres`, default `sf`) sets the unit of the `area` values; `area_sqft` is always square feet.

An aerial takeoff:

```json
{
  "data": {
    "id": 41022,
    "name": "Front lawns",
    "type": "aerial",
    "mode": "auto",
    "ai_detection": true,
    "job_id": 7012,
    "unit": "SF",
    "quantities": {
      "areas": [
        {"class": "lawn", "label": "Lawn", "count": 4, "area_sqft": 18250.4, "area": 18250.4},
        {"class": "mulch", "label": "Mulch / Beds", "count": 9, "area_sqft": 3120.0, "area": 3120.0}
      ],
      "objects": [
        {"class": "deciduous", "label": "Deciduous", "count": 31},
        {"class": "shrub", "label": "Shrub", "count": 112}
      ],
      "total_area_sqft": 21370.4,
      "total_area": 21370.4,
      "tree_count": 31,
      "object_count": 143
    },
    "editor_url": "https://trutec.ai/measurements/41022/edit"
  }
}
```

A plan takeoff:

```json
{
  "data": {
    "id": 41090,
    "name": "Maple Ridge Blueprint Takeoff",
    "type": "blueprint",
    "editor": "plan",
    "page_count": 4,
    "unit": "SF",
    "quantities": {
      "plants": [
        {"id": "cat_lsc_ai_ar_red_maple", "name": "AR — Red Maple", "code": "AR", "common_name": "Red Maple",
         "kind": "plant", "unit": "EA", "quantity": 14, "quantity_source": "callouts", "dot_count": 6, "pages": [1]},
        {"id": "cat_lsc_co_sod", "name": "Sod", "kind": "callout", "unit": "SF", "quantity": 12400,
         "quantity_source": "callouts", "dot_count": 3, "pages": [1, 2]}
      ],
      "plant_total": 14,
      "areas": [
        {"id": "bed_1", "name": "Shrub beds", "count": 7, "area_sqft": 2210.5, "area": 2210.5,
         "quantity_sqft": 2210.5, "quantity": 2210.5, "quantity_source": "drawn"}
      ],
      "lines": [{"id": "cat_fence", "name": "Fence", "linear_feet": 412.0}],
      "other_counts": [],
      "irrigation": {
        "heads_and_valves": [
          {"id": "cat_lsc_irr_sym_rotor", "name": "Rotor", "unit": "EA", "quantity": 38, "quantity_source": "dots",
           "dot_count": 38, "pages": [3], "plan_quantity": 38}
        ],
        "pipes": [{"id": "cat_lsc_irr_pipe_lateral_1", "name": "Lateral 1\"", "linear_feet": 1840.2}],
        "zones": [{"zone": "A1", "valve_size": "1\"", "gpm": 14, "heads": {"Rotor": 6}, "lateral_lf": {"1\"": 210}}]
      }
    },
    "schedule": {"status": "boxed", "page": 1, "box": [2210, 140, 3020, 980], "rows": [], "rows_ignored": false},
    "editor_url": "https://trutec.ai/measurements/blueprint?job_id=7012&measurement_id=41090&ai=1&mode=landscape"
  }
}
```

| Field | Description |
|---|---|
| `plants[].quantity` | What the takeoff counts for the plant: the amounts its callouts state ("(4) AR" = 4), else one per dot; or a total someone typed in. |
| `plants[].quantity_source` | `callouts`, `dots` or `typed`. |
| `plants[].kind` | `plant` (plant symbols), `callout` (an item read from callout text, which can be SF or LF), `callout_tag` (callout tags not read yet), `other`. |
| `plants[].code`, `common_name` | Split from names like "AR — Red Maple". |
| `areas[]` | Layers drawn on the plan (plant beds, sod, mulch), with `quantity_sqft` = a typed total when there is one. |
| `irrigation` | Heads, valves and equipment counted by the irrigation tool (`plan_quantity` = what the sheet's legend says), pipe linear feet, and valve zones. |

## Items

### List items

`GET /v1/landscape-takeoffs/{takeoff_id}/items`

**Permission:** Read measurements

Paged with `limit` (default 50, at most 200) and `offset`.

- Aerial: each area (`id`, `class`, `label`, `kind: "area"`, `area_sqft`, `rings` of [latitude, longitude]) and each tree or object (`kind: "object"`, `lat`, `lng`). Filter with `class`.
- Plan: each plant dot (`id`, `plant_id`, `plant_name`, `kind`, `page`, `x`, `y` in sheet pixels at 144 dpi, `quantity`, `callout_quantity` when a callout states one). Filter with `plant_id`, `kind` or `page`.

### Add areas and trees (aerial)

`POST /v1/landscape-takeoffs/{takeoff_id}/items`

**Permission:** Write measurements

`items` is a list of up to 200: `{"class": "mulch", "polygon": [[lat, lng], ...]}` for an area class, `{"class": "palm", "point": [lat, lng]}` for a tree or object class, each with an optional `phase` ("Phase 2"; default Phase 1). Coordinates are [latitude, longitude]. Returns `201` with the items added and the takeoff's new quantities. Plant dots on plans are placed in the editor.

### Change an item

`PATCH /v1/landscape-takeoffs/{takeoff_id}/items/{item_id}`

**Permission:** Write measurements

- Aerial: `class` (an area stays an area class, a tree or object stays an object class) and/or `phase` (`null` clears it).
- Plan: `plant_id` moves the dot to another plant; `quantity` sets the amount the dot stands for, as a callout does ("(4) AR"); `null` clears it.

Totals follow the change.

### Remove an item

`DELETE /v1/landscape-takeoffs/{takeoff_id}/items/{item_id}`

**Permission:** Write measurements and Delete records

Returns `204`.

Every change is saved the way the editor saves: the takeoff's other items are untouched, its totals follow, and the version it replaced is kept in the takeoff's history in TruTec. If someone saves the takeoff in the editor while the API is changing it, the API answers `409` (`Takeoff changed`): read it again and retry. A takeoff sent to TruTec for cleanup can't be changed until the cleanup is done (`403`).

## Measure an aerial takeoff

`POST /v1/landscape-takeoffs/{takeoff_id}/detect`

**Permission:** Write measurements

Runs the landscape model inside the outline you send and puts the result on the takeoff, the way Run does in the editor.

| Name | Type | Required | Description |
|---|---|---|---|
| `polygon` | array | One of three | One outline: [latitude, longitude] points. [Find parcels](measure-a-site.md#measure-by-parcel) gives a parcel's outline. |
| `polygons` | array | One of three | Up to 20 outlines. |
| `bounds` | object | One of three | `{north, south, east, west}`. Up to 1,000 acres. |
| `area_classes` | array | No | Only these area classes. |
| `include_areas` | boolean | No | Default `true`. |
| `include_objects` | boolean | No | Trees, shrubs and objects. Default `true`. |
| `replace_existing` | boolean | No | `true`: replace the classes the model found (the editor's Replace). `false` (default): add alongside what is there. |

```bash
curl -X POST https://trutec.ai/api/v1/landscape-takeoffs/41022/detect \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"polygon": [[30.40112, -97.72231], [30.40112, -97.72102], [30.39991, -97.72102], [30.39991, -97.72231]]}'
```

Returns `202` with the run (`id`, `status: "pending"`) and `poll_url`. It takes 30 seconds to a few minutes.

As in the editor: new areas never cover road or parking area already on the takeoff; with "add", they are also clipped to everything already there and trees within a meter of one already counted are skipped. The run is refused before it starts when the takeoff is Manual Aerial, when AI auto-measure is turned off for the person who created the key, or when the outline is too far from the takeoff's site.

**Billing.** A takeoff's first model run uses one takeoff credit from the plan, as in the editor. Runs on the same takeoff after that are free, so measure more of a site by running again on its takeoff, not by starting a new one. A refused run costs nothing. A workspace can have 3 landscape runs going at once.

### Get a run

`GET /v1/landscape-runs/{run_id}`

**Permission:** Read measurements

`status` is `pending`, `processing`, `completed` or `failed`. A completed detection's `result` has `added` (`areas`, `objects`), `removed` (items a Replace took off), `duplicates_skipped` and the takeoff's new `quantities`. A failed run has `error`.

## Plants (plan takeoffs)

### Change a plant

`PATCH /v1/landscape-takeoffs/{takeoff_id}/plants/{plant_id}`

**Permission:** Write measurements

| Name | Type | Description |
|---|---|---|
| `name` | string | Up to 120 characters, for example "AR — Red Maple 2.5\" cal.". The editor's built-in items keep their names (`422`). |
| `quantity` | number or null | A typed total: it stays until cleared, as in the editor. `null` goes back to the counted total. |

Returns `200` with the plant.

### Merge plants

`POST /v1/landscape-takeoffs/{takeoff_id}/plants/{plant_id}/merge` with `{"into_plant_id": "..."}`

**Permission:** Write measurements

Moves every dot of `plant_id` to `into_plant_id` and removes `plant_id`: for a species the grouping split in two. Both must count in the same unit. The kept plant's total follows its dots unless it has a typed total.

### Remove a plant

`DELETE /v1/landscape-takeoffs/{takeoff_id}/plants/{plant_id}`

**Permission:** Write measurements and Delete records

Removes the plant and all its dots. Returns `204`.

## Plant schedule

`GET /v1/landscape-takeoffs/{takeoff_id}/schedule` and `PUT` the same path.

**Permission:** Read measurements to read; Write measurements to change

The plant schedule is the table of plant codes and names on the plan. Grouping plants in the editor reads it to name each group. `status` is `boxed` (an area of a sheet), `on_page`, `no_schedule` (the plan has none) or `not_set`; `rows` are the rows read from it (`row`, `code`, `name`).

`PUT` takes one of:

- `{"page": 2}` or `{"page": 2, "box": [x0, y0, x1, y1]}`: where the schedule is (box in sheet pixels at 144 dpi; [find text](#find-text-on-a-plan) "PLANT SCHEDULE" gives a position). A new place starts without rows.
- `{"no_schedule": true}`: the plan has no schedule.
- `{"rows": [{"row": 3, "code": "LAV", "name": "Lavender"}, {"row": 5, "remove": true}]}`: fix rows already read. `409 No schedule rows yet` until the editor has read them.

## Counting plants on a plan

These runs do what the plan editor's plant tools do, on one sheet at a time. Each starts a run (`202`); poll `GET /v1/landscape-runs/{run_id}`. They are not billed (only starting a plan takeoff uses a credit) and need Blueprint takeoffs on the plan.

| Endpoint | Editor tool | What it does |
|---|---|---|
| `POST .../find-plants` | Find plants / Find callouts | A dot on every plant symbol (item "Plants") and callout tag (item "Plant callouts"). `lanes`: `both`, `plants` or `callouts`. `region`: an outline to scan. |
| `POST .../group-plants` | Group plants, then Name classes | Sorts the sheet's unsorted plant dots into species ("Type 1", "Type 2"…), then names each from the plant schedule ("AR — Red Maple"). `species_count` fixes how many species. `name_groups: false` stops after grouping. |
| `POST .../name-plants` | Name classes | Names the groups still called "Type N". |
| `POST .../read-callouts` | Read callouts | Reads each unread callout tag and moves it to its item (`cat_lsc_co_*`) with the amount it states: "(4) AR" counts 4, "1,200 SF SOD" counts 1,200 SF. |
| `POST .../advanced-run` | Advanced run | A close read of the sheet or a `region`, tile by tile. `mode: "plants"` reads the drawn symbols with name, code and condition; `mode: "text"` reads plant labels written as text. Takes a few minutes. |
| `POST .../read-schedule` | (the schedule read naming uses) | The schedule's plant codes, names and stated quantities. Changes nothing. |

```bash
curl -X POST https://trutec.ai/api/v1/landscape-takeoffs/41090/find-plants \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"page": 1}'
```

A completed run's `result`:

```json
{
  "takeoff_id": 41090,
  "page": 1,
  "summary": {"plants_found": 212, "callouts_found": 48, "already_marked": 0},
  "items": [
    {"id": "cat_lsc_plant", "added": 212},
    {"id": "cat_lsc_callout", "added": 48}
  ],
  "applied": true
}
```

### Runs only add, and can be undone

A run never changes what is already on the plan unless you send `"replace_existing": true`:

- **find-plants** only adds: a symbol or tag that already has a dot is skipped, so running it again fills in what the last run missed.
- **group-plants** sorts only the dots still in "Plants". With `replace_existing` it re-sorts every plant dot on the sheet, as the editor's Group does.
- **name-plants** names only groups still called "Type N". With `replace_existing` it renames every group on the sheet.
- **read-callouts** reads only tags not read yet and removes nothing: tags the reader says are not callouts, or are duplicates, come back in `flagged` for you to check. With `replace_existing` it re-reads every tag, removes the false ones and clears the items' typed totals, as the editor does.
- **advanced-run** only adds. It adds the clear proposals; proposals that are unclear, possible duplicates, already dotted or existing plants are left out unless you send `include_flagged`, and then they are marked for review in the editor.
- **irrigation** with `apply` refuses a sheet that already has reader results unless you send `replace_existing` (it then replaces the reader's own earlier results; anything drawn by hand stays).

Every applied run can be undone with `POST /v1/landscape-runs/{run_id}/undo`. Undo removes the dots the run added, moves back the dots it moved, and restores the names, quantities and settings it changed. Anything that was changed again since then (by the editor or another run) is left alone.

To review before anything changes, send `"apply": false`. The run's `result` then shows what it would do (`items`; every `proposals` entry for an Advanced run, with `selected`, `needs_review`, `already_on_plan` and `possible_duplicate`). Apply it with `POST /v1/landscape-runs/{run_id}/apply`. For an Advanced run, `proposal_ids` picks which proposals to add. Anything changed on the takeoff since the run is skipped and counted in `conflicts`.

### Check a sheet

`GET /v1/landscape-takeoffs/{takeoff_id}/sheets/{page}/image` returns the sheet as a JPEG, with every dot and drawn area in its item's color (`style=annotated`, the default) or plain. `crop=left,top,right,bottom` (fractions of the sheet) zooms in. `POST .../sheets/{page}/image-link` returns a link that opens the same picture for 24 hours without a key; anyone with the link can see the sheet.

### Server renders

The editor renders sheets in your browser. These runs render them on TruTec's servers at the same scales and send the models the same crops, tiles and schedule pictures. The two renderers are different programs, so a server render can differ slightly from the editor's, for example in line weights and text smoothing. Results can therefore differ a little from a run in the editor; check them with the sheet picture or in the editor. Sheets stored as pictures, not PDF pages, can only be read in the editor.

## Irrigation

`POST /v1/landscape-takeoffs/{takeoff_id}/irrigation` with `{"page": 3}`

**Permission:** Read measurements

Reads one irrigation sheet with the editor's irrigation tool: heads, valves and equipment by type with counts, pipe linear feet by class and size, and valve zones, each checked against the sheet's own legend (`plan_quantity`, `plan_linear_feet`, `check`). `mode` is `all` (default), `symbols` or `pipes`. Returns `202` with a run; poll `GET /v1/landscape-runs/{run_id}`. Not billed.

By default nothing on the takeoff changes. Send `"apply": true` (needs **Write measurements**) to put the result on the takeoff the way Find irrigation does in the editor:

- a count item per legend row (`cat_lsc_irr_sym_*`), one dot per head, valve or piece of equipment;
- the valve tags, with zone, valve size and GPM, which feed the zone quantities;
- a line item per pipe type and size (`cat_lsc_irr_pipe_*`), its footage measured at the sheet's scale;
- the legend's totals, for the editor's legend check;
- the sheet's printed scale, when the sheet has no scale yet.

If the sheet already has reader results, send `"replace_existing": true` to replace them; otherwise it answers `409`. A read run without `apply` can also be applied later with `POST /v1/landscape-runs/{run_id}/apply`.

```json
{
  "data": {
    "id": "5e2b1c9a-7d3f-4a10-8b6e-0c4d9f2a1e77",
    "type": "irrigation",
    "status": "completed",
    "takeoff_id": 41090,
    "result": {
      "page": 3,
      "sheet_kind": "plan",
      "scale": {"ft_per_in": 20, "source": "printed"},
      "symbols": [{"name": "Rotor", "kind": "head", "count": 38, "plan_quantity": 38, "check": "ok"}],
      "pipes": [{"name": "Lateral", "kind": "lateral", "size": "1\"", "linear_feet": 1840.2, "plan_linear_feet": 1900, "runs": 41, "check": "ok"}],
      "pipe_classes": [{"name": "Lateral", "kind": "lateral", "plan_linear_feet": 1900, "found_linear_feet": 1840.2, "status": "ok"}],
      "zones": [{"zone": "A1", "valve_size": "1\"", "gpm": 14, "heads": {"Rotor": 6}}],
      "notes": [],
      "saved_on_takeoff": false
    }
  }
}
```

A sheet that is a scan, with no drawing lines, may not be readable: the run then fails with a message saying so.

## Find text on a plan

`GET /v1/landscape-takeoffs/{takeoff_id}/sheet-text`

**Permission:** Read measurements

The editor's Find text. `q` with no `page` searches the text of every sheet (the first 60). `page` reads one sheet: `mode=text` (default) is the PDF's own text; `mode=full` also reads sheets that have none (scans, outlined text), which can take several calls: repeat while `complete` is `false`. Each match has `page`, `text`, and `x`, `y` (where the text starts, in sheet pixels at 144 dpi).

```bash
curl "https://trutec.ai/api/v1/landscape-takeoffs/41090/sheet-text?q=plant%20schedule" \
  -H "X-API-Key: sk_your_api_key_here"
```

## What stays in the editor

Drawing by hand on the plan sheet: plant beds and other layers, painting plants into classes, and dots placed one by one. Correcting the schedule's row boxes also happens there; through the API you can fix a row's code and name. On aerial takeoffs the editor's road and parking-stall lanes stay in the editor; measure pavement with [Measure a site](measure-a-site.md).
