Backend Sidecar¶
The Python backend is a normal FastAPI project in backend-api/. In
production it's bundled by PyInstaller into a self-contained directory
that ships with the Electron app.
How it's built¶
npm run build:backend runs scripts/build-backend.mjs, which calls
PyInstaller in --onedir mode. The output lands in
resources/phytograph_backend/:
resources/phytograph_backend/
├── phytograph_backend # the executable
└── _internal/ # libs + data files
The script auto-discovers backend-api/venv/bin/python. You can override
with PYTHON=/path/to/python if needed.
PyHelios is a source submodule
PyHelios is not a pip wheel — it's vendored as a git submodule at
pyhelios/ (with its own nested helios-core C++ submodule) so it can
be co-developed alongside Phytograph. scripts/build-pyhelios.mjs
compiles the native libhelios from source (the plantarchitecture and
lidar plugins; --nogpu drops only the radiation/OptiX plugin, not the
lidar CUDA ray-tracing path, which compiles when a CUDA toolkit is
present — the release workflow installs one on Windows + Linux, so those
builds are GPU-accelerated while macOS stays CPU-only) into
pyhelios/pyhelios_build/build/lib/ and installs the package editable.
The lidar plugin pulls in the visualizer plugin at the C++ level, so
OpenGL (glfw/glew/freetype) compiles too — no extra packages on macOS
(Cocoa) or Windows (native GL); Linux would need libgl1-mesa-dev /
xorg-dev. Prereqs: cmake + a C++ compiler (Xcode Command Line Tools on
macOS, MSVC on Windows). Native libs + textures +
xml asset trees still travel with the PyInstaller bundle via
--collect-all pyhelios in scripts/build-backend.mjs.
backend-api/main.py puts the submodule on sys.path at import time and
auto-rebuilds libhelios when any .cpp/.hpp/.h under helios-core/
or native/ is newer than the compiled lib — so editing the Helios C++
and restarting the backend recompiles automatically. The first
npm run dev / npm run build:backend on a fresh clone compiles Helios
(several minutes); both scripts pre-build it when the lib is missing.
The build is idempotent: re-running npm run build:backend replaces
the prior bundle in place.
How it's supervised¶
src/main/backend.ts is the supervisor. On Electron startup it:
- Resolves its port (
resolvePort()):PHYTOGRAPH_BACKEND_PORTif pinned, otherwise a freshly chosen free port.BACKEND_PORT_PROD(8008) is only the standalone-launch default baked intobackend_wrapper.py. - If
PHYTOGRAPH_DEV_BACKEND=1(set byscripts/dev.mjswhen it has spawneduvicorn --reload), it stands down immediately — killing the port would defeat hot-reload. - Otherwise it probes that port for an existing backend by hitting
/version. - If a compatible backend is already there, it reuses it.
- If one answers with a mismatched version, it's killed and the bundled binary respawned.
- If nothing answers, it spawns
resources/phytograph_backend/phytograph_backendfresh and waits for it to come up.
Because each instance picks its own port, a second app instance or a test run never disturbs a developer's running dev backend — and the supervisor never pre-emptively kills a port it couldn't version-probe. See Version Lock for the contract details.
How it's addressed¶
The renderer resolves the port over the backend.getInfo IPC, so every row
below is http://127.0.0.1:<resolved-port>:
| Environment | Who serves it |
|---|---|
Dev (npm run dev, venv present) |
uvicorn --reload, spawned by scripts/dev.mjs; supervisor stands down |
| Dev (no venv) | Supervisor spawns resources/phytograph_backend/phytograph_backend |
| Packaged build | Bundled binary alongside the app |
Wire format: JSON vs binary frames¶
Most endpoints exchange JSON. The large array responses — Helios + Open3D
triangulation (/api/triangulate*) and synthetic LiDAR scans (/api/lidar/scan)
— instead return a compact PHB1 binary frame (application/octet-stream):
magic 'PHB1' | uint32 header_len | JSON header (space-padded to 4 bytes) | buffers…
The JSON header carries the scalar metadata (meta) plus a descriptor list for
the buffers (name, f32/u32, length); the buffers (vertices, indices,
points, scalars…) follow concatenated, 4-byte aligned. The renderer reads them
as zero-copy Float32Array/Uint32Array views — no JSON.parse, no
.flat(), and no V8 ~512 MB string-length ceiling (a full-resolution tree
triangulation is hundreds of MB). Long computations stream 4-byte whitespace
keepalives ahead of the frame so WebKit's stall timeout doesn't fire; the
decoder skips them. Helpers: _bin_frame_bytes / _bin_frame_streaming_response
(backend) and decodeBinaryFrame / fetchBinaryFrame (renderer). The older
point-cloud import path uses a similar fixed PHX1 layout. Other endpoints
(LAD, plant, QSM) stay JSON — their payloads are small or texture-dominated.
When to rebuild¶
- After any change to
backend-api/main.pythat you want reflected innpm run dev(unless you run uvicorn manually). - After bumping
requirements.txt. - Before shipping a release — the CI workflow does this for you.