Skip to content

HTTP Endpoints

All endpoints listed below are served by backend-api/main.py on http://127.0.0.1:<backend-port> — the port is chosen dynamically per app instance (see Processes & IPC). The tables are grouped by feature area. To find a handler, grep ^@app\. in main.py.

Health & metadata

Method Path Source Purpose
GET / main.py Root ping
GET /health main.py Liveness probe. Also reports memory (physical / available / RSS / budget / admission_budget_bytes, from memory_budget.py; read by Settings to show what the budget resolved to) and admitted (operations currently counted against the memory budget)
GET /version main.py Returns BACKEND_VERSION (used by the supervisor)
GET /api/device-info main.py Reports whether synthetic-scan ray tracing runs on GPU or CPU. Shipped Windows/Linux builds always contain the CUDA path (the release CI fails otherwise) and macOS never does, so the path is decided by a runtime probe for a usable NVIDIA GPU (gpu_present/gpu_count/gpu_name/driver_version, via pyhelios.runtime.get_gpu_runtime_info — mainly nvidia-smi). effective_path is "gpu" when a GPU is present on a non-macOS host, else "cpu" (Helios falls back to CPU/OpenMP; cudart is statically linked so a GPU build still runs driverless). reason is a human-readable explanation. The renderer surfaces this as the GPU/CPU pill in the Synthetic Scan Options dialog

Curve / surface fitting

Method Path Source Purpose
POST /api/fit main.py Fit a built-in model to data
GET /api/models main.py List available fitting models
POST /api/fit/custom main.py Fit a user-supplied model expression
POST /api/fit/prospect main.py PROSPECT leaf optical model
POST /api/fit/crown main.py Fit crown shapes + derive per-tree metrics. Streams PHP1 progress ahead of a JSON tail (one entry per fitted crown: mesh arrays, a metrics block, and a shape-dependent params block giving the fit's defining parameters); cancelable via the run-id token

LaTeX & export

Method Path Source Purpose
POST /api/latex main.py Render expressions to LaTeX
GET /api/latex main.py Retrieve a previously rendered expression
POST /api/export main.py Export fit results

Meshing & sampling

Method Path Source Purpose
POST /api/triangulate main.py Triangulate a point cloud
POST /api/triangulate/helios main.py Helios-style triangulation. Each scans[] entry carries its own acquisition geometry (origin, n_theta/n_phi, theta_min/max, phi_min/max); an optional grid (center/size + nx/ny/nz) comes from a voxel box. With no grid the backend auto-fits a single cell over all points and sets grid_warning on the response. Each scan is triangulated independently, so the response includes triangle_scan_ids — the source scan index per triangle — for coloring by scan
POST /api/lidar/scan main.py True ray-traced synthetic LiDAR scan via the PyHelios lidar plugin. meshes[] carry world-space vertices/triangles (+ optional per-vertex colors); scanners[] carry each scanner's renderer id plus its ScanParameters (origin, n_theta/n_phi, theta_min_deg/max, phi_min_deg/max, return_mode (single/multi), max_returns (multi), return_selection (strongest/first/last, single), exit_diameter_m, beam_divergence_mrad). A legacy return_type (single/multi) is still accepted and mapped to return_mode. Optional extra_fields[] names custom primitive-data labels to sample onto hits (column-format driven). All meshes load into one Helios Context; scanners are added in order so the Helios scanID equals the request index, and each scan's stored ReturnMode/maxReturns/selection is set via the per-scan setters. syntheticScan ray-traces once (one global rays_per_pulse: every scan fires that many sub-rays across its beam cone, and rays_per_pulse=1 collapses the cone to one exact ray per pulse — the idealized scan) and hits are partitioned back per scanner via getHitScanID. Optional synthetic_scan_memory_budget_mb caps the transient ray-tracing scratch buffers (via LiDARCloud.setSyntheticScanMemoryBudget) so a large fan-out is chunked instead of traced in one OOM-prone batch; omitted/null/≤0 leaves Helios's automatic path-dependent default (4 GiB CPU / 8 GiB GPU) in place, and chunking is result-invariant. Returns results[] — one per scanner (scanner_id, points, colors, and scalars{}: intensity/distance/timestamp/target_index/target_count read via getHitData) — occlusion-aware, unlike random surface sampling
POST /api/mesh/import main.py Parse a textured .obj (+ sibling .mtl + images) or a .ply polygon mesh (ASCII or binary, with per-vertex color) from a disk path into geometry, V-flipped per-vertex UVs, per-material triangle groups, and base64-encoded textures. Returns a PHB1 binary frame: geometry rides in the buffers (vertices, indices, and optional normals/colors/uv_coordinates) while materials/textures ride in the JSON meta. JSON would break here — a scanner-grade mesh (millions of triangles) serializes past V8's ~512 MB string cap, where the renderer's response.json() throws ERR_STRING_TOO_LONG regardless of timeout. Streamed behind PHP1 progress markers (so a PLY import that waits for Open3D to load says so); a missing file, an unsupported extension, or a face-less PLY header is still rejected with a real 404 / 400 before the stream starts
POST /api/triangulate/check-spacing main.py Opt-in diagnostic cross-checking the auto-estimated Lmax against actual in-grid point spacing (the renderer offers it when the Otsu indicators aren't both High). Builds a KD-tree over up to tens of millions of points, so it streams keepalive whitespace to survive WebKit's ~60s stall timeout, then yields the JSON verdict. Reuses HeliosTriangulationRequest

Scanning support & job control

Method Path Source Purpose
POST /api/scan/export-xml main.py Export objects to a Helios XML + per-scan data bundle (or, with write_xml: false, one data file per object in data_format). Entries need only origin + a point source — the Export window lists every cloud, so a plain import with no scan geometry exports fine to the data formats (only the XML bundle and PTX need the sweep/grid, and the UI blocks those rows). Streams PHP1 progress markers ahead of its JSON tail, one per object (weighted by point count), and is cancelable via /api/cancel/{run_id} — a cancel unlinks the files it had already written so a partial batch never looks complete. Set dest_dir (absolute) and the backend writes every file itself, returning metadata only with data: null and written: true — the app always does this, and it's the only form that works for more than one scan: this response carries every selected scan at once, so base64 (~1.33x inflation) overruns V8's ~512 MB string cap in the renderer's response.json() and fails as "Unexpected end of JSON input" — the same trap as dest_path on /api/pointcloud/export, but reached sooner because the payload scales with the number of scans. Omitting dest_dir keeps the legacy base64-in-data response for callers with no filesystem destination
POST /api/trajectory/parse main.py Parse a binary trajectory (SBET .sbet/.out) into the canonical PoseStream wire dict. Server-side because it needs pyproj for the geographic→UTM projection. Text trajectories (.csv/.txt/.tsv/.traj) are parsed in the renderer and never reach this endpoint
POST /api/cancel/{id} main.py Cancel an in-flight streaming op (point-cloud import / synthetic scan / triangulation / LAD / DEM / crown fit / point-cloud + object export). Streaming endpoints emit their run_id as the first PHP1 marker; POSTing it here stops the work and frees the C++/numpy memory without waiting for the computation to finish. Idempotent — an unknown or already-finished id returns canceled: false rather than an error

Skeleton extraction

Method Path Source Purpose
POST /api/skeleton/extract main.py Extract a topological skeleton

QSM (Quantitative Structure Model)

Method Path Source Purpose
POST /api/qsm/build main.py Reconstruct a dormant tree as connected cylinders with radii + topology, segment continuous shoots, and classify them by shoot rank (trunk=0, scaffolds=1, …)
POST /api/qsm/import main.py Read a QSM back from a per-cylinder CSV (qsm/csv_io.py), reconstructing the shoot table and recomputing metrics. Returns the same QSMBuildResponse shape as /api/qsm/build, so an imported QSM needs no separate renderer path
POST /api/qsm/phyllotaxis main.py Auto-detect the phyllotactic angle from the QSM's branching geometry (child-shoot azimuths around each parent). Returns a canonical angle + pattern + leaves-per-node + confidence; pre-fills the Add Leaves modal
POST /api/qsm/leaves main.py Place leaves on the QSM's terminal shoots and return a textured mesh
GET /api/qsm/leaf-textures main.py List the curated built-in leaf textures available for QSM leaf placement
POST /api/qsm/adjust-leaf-angles main.py Rotate placed leaves so each voxel cell's leaf-angle distribution matches a target measured from a leaf-on triangulation, via per-cell optimal assignment. Takes either a triangulation or precomputed cell_targets. Accepts JSON or a PHB1 binary frame (tin_vertices/tin_indices/tin_cell_ids, plus optional tin_scan_ids/tin_scan_origins, with the grid under grid_for_triangulation in the header meta). The renderer always sends the frame — this tool only accepts full-resolution Helios triangulations, which do not fit in a JSON body

Takes inline points or a source descriptor (octree-backed clouds). The full pipeline lives in the qsm/ package and is a thin call from the endpoint: geodesic level-set skeleton → segment tree + GrowthLength continuation + shoot rank (largest-GrowthLength axis continuation; trunk=0) → robust IRLS cylinder fit + SurfCov/mad → monotone-taper radius correction (anchored to a per-species twig_radius_mm, default 4.23 mm) → axis termination (qsm/continuation.py) → horticultural metrics.

Axis termination lets a shoot END at a codominant fork, so a headed tree's trunk stops at the heading cut instead of being traced up one scaffold. It runs AFTER radius correction on purpose: the test is a sibling-radius comparison (r2/r1 >= fork_symmetry, default 0.75, aRchi's published value) plus a subtree-size floor, and only the corrected radii separate it — measured at a real redbud's true fork, symmetry is 0.50 on the provisional radius, 0.39 on the raw fit and 0.90 after correction. Set axis_termination: false to restore the never-terminating behavior, or raise/lower fork_symmetry per tree architecture.

Returns cylinders[] (each with start/end/radius/parent_id/shoot_id/ rank/surf_cov/mad), shoots[] (continuous axes with rank + parent/child links), and a metrics block (TCSA, trunk diameter, height, scaffold count, woody volume split stem-vs-branch, plus per-rank length/diameter/crotch-angle). The headline output is the per-shoot rank — topological branching order with axis continuation (NOT Strahler). Validated against PyHelios ground-truth fixtures (backend-api/tests/qsm/) on both determinate-trunk and central-leader architectures.

Ground segmentation

Method Path Source Purpose
POST /api/segment/ground main.py Classify points into ground (1) / plant (2) via the Cloth Simulation Filter. Takes inline points or a source descriptor (read at full resolution — no downsampling, so labels align 1:1). Returns per-point labels + counts. Used for flat (in-memory) clouds; session clouds use /api/cloud/session/{id}/segment_ground instead. A cloth too fine for the extent is refused with 400 (see the session variant)

The classifier is the cloth-simulation-filter package (import CSF), a SWIG C-extension bundled via collectAll in scripts/build-backend.mjs.

Noise filtering

Method Path Source Purpose
POST /api/pointcloud/denoise main.py, denoise.py Classify points into clean (1) / noise (2). Takes inline points or a source descriptor (full resolution, labels align 1:1). Returns per-point labels, the flagged count/fraction, the resolved parameters, and any warnings. Used for flat (in-memory) clouds. Above PHYTOGRAPH_DENOISE_TILE_MIN_POINTS (4 M) the radius and voxel criteria run per buffered tile with parameters resolved once (stats.tiled); SOR stays global
POST /api/cloud/session/{id}/denoise main.py, denoise.py Same classification on the session's in-RAM hit survivors, appending a noise_class column and rebuilding the octree so the flagged points render. Nothing is deleted — removal goes through /filter or /split

Three criteria, all in backend-api/denoise.py as index-preserving keep-masks: ror (radius outlier, the default), voxel_count (O(N), for very large clouds) and sor (statistical). Any parameter left unset is derived from the cloud's own p95 nearest-neighbor spacing and echoed back in params_used.

Both routes run the compute in the killable seg_worker subprocess (tool: "denoise"), so a client disconnect actually stops it — a single cKDTree.query is a monolithic C call that cannot poll a cancel flag.

Don't change the SOR default to 2.0

DEFAULT_SOR_STD_RATIO is 4.0, not the conventional open3d value. SOR's threshold is set by the extremes still present in the cloud, so it becomes more aggressive on each pass — measured at 73% of a test tree's twig points destroyed on a second run. The reasoning and the numbers are in denoise.py's module docstring, pinned by tests/test_denoise.py::test_sor_second_pass_eats_fine_structure.

Tree segmentation

Method Path Source Purpose
POST /api/segment/trees main.py Segment individual trees with TreeIso (cut-pursuit graph method, CPU-only) or, with method: "chm", a canopy-height-model watershed (chm_trees.py; chm_crown_scale (about the width of the smaller crowns; default 2.5 m), chm_min_height, optional chm_cell — no cost prompt, tiling or fusion_warning). Takes inline points or a source descriptor (full resolution; labels align 1:1) and optional seed_points (trunk seeds for human-in-the-loop — each seed yields one tree). Returns per-point labels (0 = unassigned, 1..N = trees), num_trees, ground_warning (true when no ground_class labels came with the points, i.e. Ground Segmentation was not run — a reminder, not a detection), and fusion_warning — set when an instance holds several trunks, i.e. trees were probably merged and num_trees is too low (see _treeiso_row_fusion_warning). Used for flat clouds; session clouds use /api/cloud/session/{id}/segment_trees instead

TreeIso is vendored (MIT) under backend-api/vendor/treeiso/; its graph-cut backend cut_pursuit_py is bundled via collectAll in scripts/build-backend.mjs. No GPU or PyTorch required.

Wood / leaf segmentation

Method Path Source Purpose
POST /api/segment/wood main.py Classify points into wood (1) / leaf (2) from local geometry, or with a trained model (method: "ml", optional model_id; see ML models). Aggregates multiple sources at full resolution (concatenated in order so labels slice back per source) and accepts optional per-point reflectance. Returns per-point labels aligned to input order. Session clouds use /api/cloud/session/{id}/segment_wood instead

ML models

Method Path Source Purpose
GET /api/ml/models main.py Installed point-classification models (bundled + user-imported), optionally ?task=wood_leaf. Each carries its class schema, input channels, origin and metrics
GET /api/ml/device main.py Where ML inference runs (cuda / mps / cpu), as torch reports it. Probed once in a worker (torch never loads into the server process), then cached
POST /api/ml/models/import main.py Validate a model package directory ({path}) and install it into the user model directory. Weights are loaded against the declared architecture first; a package reusing a built-in id is refused
DELETE /api/ml/models/{id} main.py Remove a user-imported model (built-ins cannot be removed)

DEM (digital elevation model)

Method Path Source Purpose
POST /api/dem main.py Generate a DEM from a flat cloud (inline points / source). Returns a PHB1 binary frame (heightmap mesh + regular grid); cancelable
POST /api/dem/export-raster main.py Write a DEM grid to ESRI ASCII (.asc) or GeoTIFF (.tif), returned base64. The renderer round-trips the grid it got from /api/dem, with voids encoded as nodata (JSON can't carry NaN) and the origin shifted back to true-world coordinates. GeoTIFF uses tifffile (pure-Python — no GDAL)

Leaf area density

Method Path Source Purpose
POST /api/lad/compute main.py Per-voxel leaf area density via PyHelios. Accepts either a JSON LADComputeRequest (fresh-triangulation path) or a PHB1 binary frame carrying the request fields plus the mesh as raw buffers — the binary path lets a 1M+ triangle mesh ride back compactly to be injected via setExternalTriangulation instead of re-triangulated. Streams PHP1 progress ahead of the JSON result. Screens occluded voxels by total probed beam path (occlusion_threshold_m, default 100x the voxel side length) and can fill them by LAD-kriging (fill_occluded); each cell reports path_length_total / under_sampled / lad_filled, and total_leaf_area counts measured voxels only. Requires misses (see the admonition below)
POST /api/lad/snap-grid main.py Sample a DEM under each voxel column so the grid can be displaced to follow the ground. Returns the authoritative per-column offsets (column_offsets) the UI renders and feeds to the inversion, plus kept_columns / dropped_columns

Plant models & sessions

Method Path Source Purpose
GET /api/plant/models main.py List available plant models
POST /api/plant/session/create main.py Start a new plant simulation session
POST /api/plant/session/{session_id}/advance main.py Advance a session in time
GET /api/plant/session/{session_id} main.py Get session status
DELETE /api/plant/session/{session_id} main.py Destroy a session
GET /api/plant/sessions main.py List active sessions
POST /api/plant/morph/parse main.py Parse a morph expression
POST /api/plant/morph main.py Apply a morph to a plant
POST /api/plant/generate main.py Generate a plant from parameters
POST /api/plant/canopy/generate main.py Generate a grid of plants as one merged mesh
POST /api/plant/generate/stream main.py Generate a plant or canopy with SSE progress

POST /api/plant/generate/stream

Generates a single plant or a canopy and streams progress as Server-Sent Events, so the UI can show a live progress bar (and cancel by aborting the request). This is the path the Generate Plant popup uses.

Request (PlantStreamRequest) carries a mode ("single" or "canopy") plus the relevant fields from PlantGenerationRequest / PlantCanopyRequest. The stream emits:

event: progress
data: {"progress": 0.0-1.0, "message": "Growing plants..."}

event: result
data: <PlantGenerationResponse-shaped JSON>

event: error
data: {"detail": "..."}

Progress maps the C++ growth phase (via pyhelios PlantArchitecture.setProgressCallback) to 0–0.6, geometry extraction to 0.6–0.95, and serialization to the final 1.0. Single-plant builds create a retained session (returned as session_id in the result) so the age slider keeps working; canopies are stateless and include the canopy echo fields.

POST /api/plant/canopy/generate

Builds a regularly spaced grid of plants from one library species (pyhelios buildPlantCanopyFromLibrary) and returns the whole canopy as a single merged mesh — the same PlantGenerationResponse shape as /api/plant/generate, so the renderer is identical.

Request (PlantCanopyRequest):

Field Type Default Meaning
plant_type str "bean" Library species (see /api/plant/models)
age float 30.0 Age of every plant, days (≥ 0)
center_x/y/z float 0.0 Canopy center, meters
spacing_x/y float 0.5 Spacing between plants, meters
count_x/y int 3 Plants in X / Y (must be > 0)
germination_rate float 1.0 Probability (0–1) each position is filled
random_seed int? null Optional seed for reproducibility
leaf_inclination {beta_mu, beta_nu}? null Steer leaf inclination toward this Beta distribution (Helios convention, mean 90·ν/(μ+ν) deg), set on the library model before the build via setPlantModelLeafInclinationDistribution. Also accepted by /api/plant/generate, /api/plant/generate/stream and /api/plant/session/create

The response echoes back plant_count (plants actually built after germination), count_x, count_y, spacing_x, and spacing_y. Invalid counts, age, or germination rate return success: false with an error message (no pyhelios work is done). helios_xml holds the first plant's structure as a representative sample.

Point cloud I/O

Method Path Source Purpose
POST /api/pointcloud/import main.py Import a LAS/LAZ file (multipart upload)
POST /api/pointcloud/preview main.py Cheaply inspect a file for the import wizard: reads only the header + first ~20 rows (ASCII) or header + a few points (PLY/PCD/LAS) and returns the detected delimiter, per-column auto-detected role, a type_hint (integer/float/categorical/empty) used to pre-tick the categorical box, sample rows, and remappable (true for ASCII, false for in-file-layout formats). Never 500s on a parse problem — returns a 200 with a warning so the wizard can still offer auto-detect
POST /api/pointcloud/import_by_path main.py Parse a point cloud from a path on disk (dispatches .xyz/.txt/.csv/.pts/.asc to pandas, .ply/.pcd to open3d). Returns a packed binary stream so multi-GB scans aren't bottlenecked by JSON encoding. Accepts an optional column_plan (the import wizard's explicit per-column roles + custom scalar slug/label + rgb_is_255 scale) that overrides auto-detection; absent → identical to the previous behavior
POST /api/pointcloud/export main.py Export a point cloud to LAS/LAZ — or, for octree-backed clouds (via a source descriptor), to any of LAS/LAZ/XYZ/TXT/CSV/PLY/OBJ (obj is still accepted here, but the Export window no longer offers it for point clouds — a .obj can only be re-imported as a mesh). The backend streams from the source file and applies any pending translation. Set dest_path (absolute) and the backend writes the file itself, returning metadata only with data: null — the app always does this, and it's the only form that works at scale: base64-in-JSON inflates the body ~1.8x, so a 25 M-point XYZ export lands near 1 GB and the renderer's response.json() throws ERR_STRING_TOO_LONG ("Unexpected end of JSON input"). Omitting dest_path keeps the legacy base64-in-data response for callers with no filesystem destination. A session exported to a LAS/LAZ file streams in 2 M-row blocks (_export_session_to_las) and never copies the survivor set

Octree building, cropping, and filtering for imported clouds go through the cloud-session endpoints (next section) — the in-RAM array is the source of truth and the octree is derived from it. There is no longer a standalone "convert/crop/segment a file into an octree" endpoint; those were removed when the session model landed.

Mutable cloud sessions (the in-RAM source-of-truth model)

Every path-imported point cloud is loaded into a cloud session: the full attribute set (positions + colors + intensity + scalar extra-dims) is held in RAM on the backend as the authoritative copy. The source file is read exactly once, at create (_source_to_las → _read_las_into_arrays); afterwards every edit mutates the in-RAM arrays and rebuilds the derived Potree octree from them (_session_to_las → PotreeConverter) — the file is never re-read. The octree is a disposable render cache; the array is the source of truth.

Deletions are an exact per-point boolean mask (instant, no rebuild); undo is a mask-snapshot stack. Compute endpoints (triangulate/skeleton/c2m/icp/export) read the masked array directly via PointSource.session_id, so they honor unbaked deletions with no rebuild. Filter and ground/tree segment run their algorithms on the array and append columns; "split"/"extract" spin off child sessions from the array. All of it is file-read-free after import.

Method Path Source Purpose
POST /api/cloud/session/create main.py Load a source file fully into a new in-RAM session and build its first octree (hits-only). Also builds a SECOND projected-miss octree when the scan has placeable sky/miss points, returned as miss_octree_cache_id. Honors the wizard column_plan once (survives all edits). The ONLY point the file is read. The source path is validated before the response opens (a missing file is a clean 404); the import then runs OFF the event loop and streams PHP1 progress markers (Reading source file… → Loading points into memory… → Building octree…) ahead of a JSON tail carrying session_id + octree metadata. Cancelable via /api/cancel/{run_id}: a cancel unwinds the worker and SIGKILLs the PotreeConverter child, so the work really stops. Because the stream is already open by then, in-flight failures come back as error in the JSON tail rather than an HTTP error status. Nothing is left behind — the temp dir and octree staging dir unwind, and the session is registered only as the final step, so a canceled import strands neither RAM nor a half-built cache entry
POST /api/cloud/session/{id}/delete_region main.py Set the per-point deleted mask for points in a region (box/polygon/squares_union). Instant — array mask only, no rebuild. Excludes is_miss points from the selection (a crop only ever deletes hits). Returns counts, plus backfilled_misses_stale: true when the crop invalidated a separately-backfilled miss buffer (kept and still usable — LAD restores deletions outside the voxel grid and clears the flag itself, warning only when points were deleted from inside it, which a re-backfill cannot repair). Clears octree_cache_id (the cached octree no longer describes the points) but carries it into rendered_octree_cache_id, which _evict_octree_cache pins — the renderer keeps streaming from that directory and masks the deleted points until a rebuild lands, so it must not be evicted underneath a visible cloud
POST /api/cloud/session/{id}/reset_edits main.py Undo: restore the deleted mask to an earlier snapshot (edit_count deletes kept)
POST /api/cloud/session/{id}/reset_label_edits main.py Undo labeling: reverse-apply the stored per-point deltas, newest first. Prefer undo_after_stroke_ids (the ids the renderer still holds, NEWEST FIRST) over edit_count for a global undo — the renderer's stroke list counts USER GESTURES while this history counts RECORDED CHANGES, and a stroke whose from-class gate matched nothing is never recorded, so the two legitimately diverge and a count silently rolls back the wrong edit. The session keeps up to the first id it recognizes (an empty list keeps nothing). stroke_id is the join key between the two lists. commit_labels drops the column's history when it arrives, so a commit is an undo boundary
POST /api/cloud/session/{id}/bake main.py Permanently apply deletions — rebuild the octree from the surviving array points (_session_to_las → PotreeConverter), compact the arrays, clear the mask. Also reprojects + rebuilds the miss octree from the survivors, returned as miss_octree_cache_id. The one deliberately-slow step. No file read. The session is resolved before the response opens (a missing session is a clean 404); the rebuild then streams PHP1 progress markers ahead of the JSON tail and is cancelable via /api/cancel/{run_id} (SIGKILLs the PotreeConverter child). Every session mutation happens AFTER the build returns, and a cancel checkpoint sits between the two, so a cancel — including one landing as the converter finishes — leaves the pending deletions exactly as they were rather than half-applying them. Returns deleted_history_len: the undo depth that survived the bake, normally 0, non-zero only when a delete landed after the compaction. The renderer's pendingDeletes stack mirrors that history one-for-one (its entries index reset_edits), so it truncates to this rather than assuming the bake absorbed everything — which matters now that an applied crop's bake runs in the background while the user can still edit. ?compact=false is that background display refresh: it rebuilds the octree but keeps the deleted rows under the mask (LAD restores the deleted hits outside its grid from them) and ends undo at the mask. Only the default compacting bake (Permanently apply deletions) removes rows, and it counts the removed hits so LAD warns. Both return pending_deleted_count, the renderer's pendingDeletedCount against the octree just built (also returned by delete_region, reset_edits and a non-rebuilding filter)
POST /api/cloud/session/{id}/filter main.py Delete the points a spatial+scalar filter excludes (array columns), rebuild from the survivors. Composes on the current survivors. Empty result → point_count: 0, no commit/rebuild
POST /api/cloud/session/{id}/split main.py Keep the filter-passing points on this session; move the excluded points to a NEW leftover session. Both rebuilt from arrays. Powers crop/filter "Segment"
POST /api/cloud/session/{id}/extract main.py Create a NEW child session from the filter-selected points, parent untouched. Always carries the parent's sky/miss points onto the child. Single-subset; the ground and per-tree splits both use the batch extract_by_column below
POST /api/cloud/session/{id}/extract_by_column main.py Batch form of extract: fan a categorical column ({slug}, e.g. tree_instance) out into ONE child session per distinct value, parent untouched. exclude_values defaults to [0] (ground/miss/unassigned). rebuild_parent (default false) additionally rebuilds the PARENT's octree in the same concurrent pool as the children and returns it as parent — it pairs with defer_octree on the segmentation endpoints so the parent's rebuild, the single biggest cost of a classify-then-split run, overlaps the children's instead of running alone to completion first (measured 15.8 s → 11.7 s on a 1.2 M-point 2-way split). include_misses (default false) additionally duplicates the parent's sky/miss rows onto EVERY child, matching what single-shot extract always does — the ground/plant split sets it so each half keeps LAD's Beer's-law transmission denominator, while the per-tree split leaves it off so ~100 tree clouds don't each carry a full copy of the miss shell. Powers split into one cloud per tree and split into ground + plant clouds. All subsets are sliced under a single lock — one stable argsort of the column, then a per-child gather by absolute row index, so a K-way split costs O(N) in total rather than K full-survivor copies — and the per-child octree builds then run concurrently in a bounded pool (_EXTRACT_BUILD_POOL). The slug is validated before the response opens (a typo is a clean 400); the builds then stream PHP1 progress markers ahead of the JSON tail so the renderer can show a cancelable Building n of N clouds… pill. A cancel or failure drops the child sessions it had already registered, so a half-finished split doesn't strand a second copy of the cloud in RAM. The cancel is observed both at each build's start AND after each completion, and the run's cancel Event is forwarded into _session_rebuild so PotreeConverter is SIGKILLed — without the latter two, a split whose K fits the pool (the 2-way ground split always does) had every build past its only checkpoint before the user could click Cancel, and the children leaked
POST /api/cloud/session/{id}/duplicate main.py Copy a session's surviving points into a NEW independent session (parent untouched) and build its octree. A pure array copy — no file read, so every wizard customization is preserved. Powers scan "Duplicate"
POST /api/cloud/session/merge main.py Concatenate the surviving points of ≥2 sessions (body {session_ids}) into one NEW session and build its octree. Reconciles differing global shifts (re-expresses every input into a common world_shift) and unions scalar extra-dim columns (zero-filling inputs that lack a column). Builds a projected-miss octree when any input carried misses. Returns {merged: {session_id, point_count, world_shift, cache_id, has_misses, miss_octree_cache_id, …octree}}. Powers Stitch Clouds — the merge runs here, not in the renderer, because octree clouds hold their points in the session (the renderer's flat positions is empty)
POST /api/cloud/session/{id}/segment_ground main.py Run CSF on the array, append a ground_class column, rebuild from arrays. defer_octree skips the rebuild and returns {octree_deferred: true} with no octree fields at all (returning the pre-column octree with a cache_id would be the stale-octree bug bake guards against) — for a caller that immediately POSTs extract_by_column with rebuild_parent. Estimates the run's wall time and memory first (_cost_advisory): past the guideline it answers 409 with a structured cost_warning, and the caller re-sends with acknowledge_cost: true; a cloth too fine for the extent (>25 M cloth nodes) is refused with 400 and the coarsest resolution that fits. Large clouds run per buffered tile (tiled.py); the response's tiled field carries the plan
POST /api/cloud/session/{id}/denoise main.py Classify noise on the array's hit survivors, append a noise_class column, rebuild from arrays. Non-destructive
POST /api/cloud/session/{id}/segment_trees main.py → tiled_trees.py Run TreeIso (or, with method: "chm", the canopy-height watershed in chm_trees.py, fed the ground_class ground points as its DTM; the response then carries a chm block with the resolved cell and ground source); ground_warning is true when the cloud has no ground_class column, i.e. Ground Segmentation was never run on the array, append a tree_instance column, rebuild from arrays. tiling (auto/on/off) and tile_buffer_m segment large plots in buffered tiles stitched by stem anchor (see Stem detection & tiling); the response's tiling block reports tiles run and trees possibly cut off by a buffer
POST /api/cloud/session/{id}/detect_stems main.py → stem_seeds.py Automatic trunk seeds from the 1–2 m height-above-ground layer (needs height_above_ground). Plain def; chunked layer read. Returns {seeds: [{x, y, z, radius_m, n_inliers, arc_coverage}], layer_points} in the session's stored frame
POST /api/cloud/session/{id}/segment_wood main.py Wood/leaf segmentation on the in-RAM survivors → append wood_class → rebuild the octree. No file read. The compute runs in a killable subprocess so Cancel can SIGKILL it; the column write + rebuild happen in the parent afterwards, so a cancel mid-compute leaves the session pristine. The renderer's "split into wood + leaf clouds" then fans wood_class out through extract_by_column (include_misses + rebuild_parent), not two serial extract calls. Supports the same defer_octree as segment_ground
POST /api/cloud/session/{id}/dem main.py DEM from the session's in-RAM survivors (ground-aware). Returns a PHB1 frame (heightmap mesh + grid). With add_height_column, also appends a height_above_ground scalar and rebuilds the octree
POST /api/cloud/session/{id}/tree_inventory main.py → tree_inventory.py Tree list + stem curve for every tree_instance > 0 (method: Tree inventory measurements). Plain def, admission-gated; indexes the eligible points (alive, not a miss) in chunks, sorts them by tree once, then gathers and measures ONE tree at a time from the (memory-mapped) session arrays. Ground from height_above_ground, else a grid of ground_class points, else the tree minimum. Then a stand pass (stand_metrics.py): plot boundary = hull of the ground points, canopy union, and per-tree Hegyi index / crown overlap / edge flag. Streams PHP1 progress then a JSON tail {trees, stem_curve, stand, ground_sources, warnings, params}; cancelable between trees via the run-id token; a bake mid-run is reported as an error, not silently mis-indexed
POST /api/project/scene main.py Stage the renderer's scene document (opaque bytes, streamed to disk) for a project save; returns a token
GET /api/project/scene/{token} main.py Download an opened project's scene document; one download, then the token expires
POST /api/project/save main.py → project_file.py Write a .phyto project: the staged scene plus every listed cloud session (all columns, pending deletes, delete/label undo history, miss buffer) and the display octrees, streamed to <path>.partial then renamed over the target. Plain def, admission-gated, cancelable. See Project file
POST /api/project/open main.py → project_file.py Read a .phyto project: install its octrees, restore each session under a NEW id (memory-mapped store for large clouds), stage the scene document. Returns {session_map, scene_token}. No pickle anywhere; a failure registers nothing
POST /api/cloud/session/{id}/tree_qsm main.py One QSM per tree_instance through the same _SessionTreeReader and the same _qsm_pipeline as /api/qsm/build. Optional wood-only filter (wood_class), per-tree voxel thinning to max_points_per_tree. A failing tree is reported in its own result, never fails the batch. Plain def, admission-gated, cancelable between stages. Returns {results: [{tree_id, success, error, points_used, metrics, cylinders?, shoots?}], warnings}
POST /api/cloud/session/{id}/transform main.py Bake an affine 4×4 (row-major, TRUE-world-frame) transform into the session geometry: rigid for ICP, rigid-plus-scale (possibly non-uniform) for the Transformation tool. The bottom row must be [0, 0, 0, 1] and the linear part must have a positive determinant (no mirroring), else 400. The session stores points with world_shift subtracted, so the matrix is conjugated by the shift — which means a caller holding a matrix built in the renderer's stored frame must conjugate it to world first (conjugateByShift in lib/affineDelta.ts). Points take the full matrix; stored normals take the inverse-transpose of its linear part (renormalized); a non-similarity flags normals stale (curvature changed); a uniform scale multiplies a distance column. A permanent, non-undoable geometry change — this is what commits a Transform. octree_mode chooses what happens to the derived octree: "rebuild" (default) returns a CURRENT octree — a pure translation takes the in-place rewrite (octree_transform.translate_octree_dir, ~1.8 s on 10 M points), a rotation reconverts (~83 s, since rotation re-buckets the octree's nodes). "pose" leaves the octree ALONE: the geometry moves (~0.5 s, and that is what every compute path reads via _read_points_from_source), the existing octree is returned unchanged, octree_posed: true is reported, and CloudSession.octree_pose is set. The renderer then draws that octree through a stored pose — correct for rendering, LOD, picking and GPU clipping alike, because potree composes the octree object's matrix.
POST /api/cloud/session/{id}/rebuild_octree main.py Rebuild a session's octree(s) from its CURRENT arrays, clearing octree_pose. The repayment step for a deferred transform. Needed only by operations that ship a screen-space region (lasso/rect crop, erase brush, label brush): those freeze a camera looking at the posed octree and the backend replays it against session positions, so the frames must agree first. Compute and export never need it.
POST /api/cloud/session/{id}/backfill-misses main.py Recover sky/miss points and persist them in a lightweight per-session buffer (CloudSession.backfilled_misses). Builds an ephemeral PyHelios cloud from the scan as measured — deleted hits are restored via _session_to_lad_arrays(restore_mask=...), because the gapfill synthesizes a miss into every raster cell with no return, so gap-filling over crop survivors alone would resurrect each cropped pulse as a transmitted beam (restored_deleted_hits reports how many were fed back) — runs gapfillMisses() (auto-selects the row/column or timestamp path; row_index/column_index are relabeled to the bare row/column keys the C++ dispatcher probes), and slices the synthesized misses via the bulk getters. Hit arrays are untouched. Rebuilds the projected-miss octree and returns its miss_octree_cache_id. Session-resolve + eligibility run up front (404, or 400 when a scan has no usable timestamp, no grid, and either moves or declares no raster). A static scan with neither column is placed by direction on its declared raster (n_theta/n_phi/ranges plus tilt_roll_deg/tilt_pitch_deg/azimuth_offset_deg, helios-core v1.3.89); a static scan's rounded timestamps are dropped, up front by _audit_pulse_columns or on Helios's own refusal via deleteHitData; the heavy build/gapfill/extract streams PHP1 progress markers ahead of the JSON tail (_do_backfill_misses + _bin_frame_streaming_response) so the renderer shows a per-stage progress bar. Short-circuits (plain JSON) when the scan already has misses; a Helios reconstruction failure (too-sparse grid) returns an error field in the JSON tail rather than a 500
DELETE /api/cloud/session/{id} main.py Free the session's in-RAM arrays (called when a cloud is removed from the scene)

LAD requires misses — no silent gapfill

/api/lad/compute no longer recovers misses on the fly. A scan must already carry sky/miss points — retained by the source format (E57 / structured PLY) or recovered up front via backfill-misses, which persists them so _session_to_lad_arrays appends them to the LAD cloud. If none are present the endpoint returns a structured success: false error directing the user to Backfill Misses (the Helios C++ calculateLeafArea fail-fast still backstops). This applies to every LAD source path, including the non-session file_path / inline-points paths, which have no backfill step and therefore must ship recorded misses.

Registration & comparison

Method Path Source Purpose
POST /api/c2m/distance main.py Cloud-to-mesh distance. A session source is streamed block by block (_iter_session_hit_positions) so the query never holds the whole cloud; only the float32 distance per point is kept
POST /api/c2m/icp-register main.py Cloud-to-mesh ICP
POST /api/c2c/icp-register main.py Cloud-to-cloud ICP
POST /api/m2m/icp-register main.py Mesh-to-mesh ICP. Accepts JSON or a PHB1 binary frame (target_vertices/target_indices/source_vertices/source_indices as buffers, scalars in the header meta). The renderer always sends the frame: the mesh picker offers Helios triangulations, and four arrays of that size as JSON numbers exceed V8's max string length, so the request died in JSON.stringify before it was sent

Reading points — the source descriptor

Octree-backed clouds keep no point positions in the renderer (the geometry lives only in the on-disk Potree octree, streamed to the GPU). So the downstream endpoints — /api/skeleton/extract, /api/triangulate, /api/c2m/distance, /api/c2m/icp-register, /api/c2c/icp-register, and /api/pointcloud/export — accept an optional source object in place of the inline points array:

"source": {
  "session_id": "3f2a…",
  "max_points": 20000,
  "translation": [tx, ty, tz],
  "want_colors": true
}

session_id is required. A cloud's file is read exactly once, at import (/api/cloud/session/create); from then on the session's in-RAM arrays are the source of truth, and they carry every edit — deletions, translation, filtering, segmentation labels — that the file on disk does not. Computing from the file would therefore return a silently wrong answer, so _read_points_from_source rejects a file-only source with a 400 for every compute and export path. This is enforced at that single chokepoint, not per-endpoint, so the rule holds for every caller.

source_path may still be sent alongside session_id, but it is provenance only and is never re-read. The allow_file_source: true escape hatch exists for files that are not live clouds (unit tests driving the loaders directly); the app never sets it.

When source is set the backend reads (and optionally stride-downsamples) the session's points, applies the pending translation (added to every point), and runs the same computation. Flat (PLY/PCD) clouds keep sending inline points unchanged. /api/triangulate returns points_used so the UI can warn when the global triangulate max points cap downsampled a large cloud.

Live API docs

FastAPI's interactive docs are exposed at /docs on the backend's port while it's running, with request/response schemas auto-generated from the Pydantic models in main.py. In a dev session the port is printed in the [dev] startup lines.