NullCad
NullCad is a 2D CAD application purpose-built for cadastral surveying, built with Rust, egui, and a custom wgpu renderer. It is designed to provide a modern, efficient, and precise alternative to the big name CAD programs.
Most CAD software is designed for engineers with surveying being an afterthought or functionality added through seperate plugins that are never quite able to overcome the inherint limitations of the native DXF/DWG file format.
This book covers how to use NullCad day to day — navigation, drawing and modify tools, snapping, layers, the survey workflow, drafting and PDF output, import/export formats, and the plugin/automation systems — as well as the file format and internal architecture for anyone extending the application.
Where to start
- New to NullCad? Start with Installation and the Interface Overview.
- Setting up a survey project? See Survey Phases.
- Producing a plan of survey for lodgement? See Page Proxies & Viewports and PDF Export.
- Extending NullCad with scripts or an AI agent? See Lua Plugins and MCP Server.
Highlights
- Phase-based survey workflow with independent camera state and status tracking per phase.
- Full snap tracking for easy and efficient drawing and editing of survey geometry.
- Vector PDF export of drafting sheets, with viewports, sheet templates, and title-block substitution.
- Pure-Rust import/export for Shapefile, GeoJSON, DXF, and CSV, with optional GDAL support for KML, GeoPackage, MapInfo TAB, and FileGDB.
- Lua plugin system and an in-process MCP server for scripting and AI agent integration.
- ZIP-based
.nullcadarchive format with full operation history, undo audit trail, and embedded resources.
License
Copyright 2026. All rights reserved.
Installation
Pre-built installers are published on the GitHub Releases page for each tagged version:
| Platform | Formats |
|---|---|
| Linux | .deb, .pacman |
| Windows | .nsis installer |
Download the installer for your platform from the latest release and run it.
If you’re on a platform without a pre-built installer, or you want the latest unreleased changes, see Building from Source.
Requirements
- A GPU with wgpu/WebGPU support (essentially any GPU from the last decade, on Vulkan, Metal, DirectX 12, or OpenGL).
- GDAL is not required for the default build — it’s only needed if you
build with the optional
gdalfeature for KML/GeoPackage/MapInfo/FileGDB support.
Interface Overview
NullCad’s main window is organized around a central canvas viewport, surrounded by panels for layers, properties, tools, and phase status.
- Phase tabs — switch between the four survey phases (Setup/Search, Boundary Model, Reinstatement, Drafting). Each phase has independent camera state and status tracking; see Survey Phases.
- Toolbar — grouped by category: Select, Draw, Modify, Survey, Measure, Surface & Drafting. See Drawing Tools and Modify Tools.
- Layer panel — create, rename, delete, and organize layers with groups; per-layer color, visibility, lock state, and line type. See Layers.
- Properties panel — inspect and edit the properties of the current selection.
- Canvas viewport — the main drawing surface, rendered with a custom wgpu pipeline for precision at large coordinates (see Precision Handling).
- Status bar — coordinates under the cursor, active tool, snap state.
- Console log (F12) — a searchable, color-coded, persistent log of all application messages.
Theme
NullCad uses the Catppuccin color scheme.
Distraction-free mode
Press F11 to hide the side panels and show a floating toolbox — useful when you want the canvas to take up the full window.
Settings
Application settings (point size, selection threshold, colors, line weight, label display, camera sensitivity, autosave interval, and more) are available from the Settings menu, organized into categories such as Settings ▸ Interaction and Settings ▸ Labels.
Recent files
The File menu keeps a list of recently opened projects for quick access.
View bookmarks
Save up to 9 camera positions and recall them instantly:
| Action | Input |
|---|---|
| Recall bookmark | 1–9 |
| Set bookmark | Ctrl+1–9 |
Navigation & Controls
Navigation
| Action | Input |
|---|---|
| Pan | Middle mouse drag |
| Zoom | Scroll wheel (zooms toward cursor) |
| Reset camera | Reset Camera button |
| Distraction-free mode | F11 |
| View bookmarks | 1–9 to recall, Ctrl+1–9 to set |
Panning and zooming are per-phase — each of the four survey phases keeps its own independent camera position, so switching phases doesn’t lose your place.
Editing
| Action | Input |
|---|---|
| Undo | Ctrl+Z |
| Redo | Ctrl+Y or Ctrl+Shift+Z |
| Delete selected | Delete |
| Explode | B |
| Transform gizmo (move/rotate selection) | W |
| Toggle console | F12 |
Undo/redo operates on the full operation history — every mutation is recorded and reversible. See Operation System.
A full, searchable list of shortcuts is available from the in-app keyboard shortcuts help dialog, and summarized in Keyboard Shortcuts & Controls.
Selection
Basic selection
| Action | Input |
|---|---|
| Select | Click on geometry |
| Multi-select | Ctrl+Click |
| Rectangle select | Click and drag on empty canvas |
| Select all | Ctrl+A |
Selection uses an R-tree spatial index for efficient hit testing, so selecting and range-querying stays fast even on large datasets.
Hover highlighting
Geometry under the cursor is highlighted before you click, giving visual feedback about what a click will select.
Selection sets
Save the current selection as a named set and restore it later — useful for repeatedly working with the same group of geometry (e.g. all boundary points, or all geometry on a particular layer).
Selection history
Navigate back and forward through previous selections, similar to browser history.
Select Similar
Filter and select geometry by type, layer, code, name, position, or length — useful for bulk operations like “select all points with code BNDRY” or “all lines on this layer.”
Context menus
Right-click on geometry or empty canvas for context-sensitive actions.
Drawing Tools
Geometry primitives
NullCad supports the following geometry primitives, all stored with full f64 precision:
- Point
- Line
- Polyline
- Polygon
- Rectangle
- Circle
- Arc
- Text
Draw toolbar
The Draw tool category provides: Point, Line, Polyline, Leader, Polygon, Text, Callout, Circle, Arc, and Rectangle.
Drawing tools respect the active snap configuration — clicks snap to endpoints, midpoints, intersections, grid points, and other configured snap targets by default.
Vertex editing
Polylines and polygons support direct vertex editing: add, remove, and move individual vertices after the geometry is created.
Intersection calculations
NullCad can compute intersection points between geometry segments — useful for finding where two lines or boundaries cross without manually calculating coordinates.
Line types
Line styles (dashed, dotted, center, etc.) are configurable per layer or per
individual geometry object. Custom linetype patterns can be imported from
AutoCAD .lin files.
Every draw operation is undoable
Like all mutations in NullCad, drawing goes through the operation system — every draw action is recorded, undoable, and tagged with the active survey phase.
Modify Tools
Basic modify tools
- Move — translate selected geometry.
- Rotate — rotate selected geometry around a point.
- Scale — scale selected geometry.
- Trim/Extend — trim or extend lines/polylines to a boundary.
- Offset — create a parallel copy of a line or polyline at a fixed distance.
Transform gizmo
Press W to activate the transform gizmo on the current selection: a drag-based move/rotate handle with snapping, angle snap, and a relocatable pivot point. This is the fastest way to reposition or reorient a group of geometry interactively.
Explode
Press B to break compound geometry into simpler components — polygons become polylines, polylines become individual lines.
Vertex editing
Add, remove, and modify individual vertices on polylines and polygons directly, without recreating the geometry.
Undo/redo
Every modify operation is recorded in the operation history and fully reversible with Ctrl+Z / Ctrl+Y. See Operation System.
Snapping
NullCad supports several snap modes, which apply while using drawing and modify tools:
- Endpoint — snap to the start/end of a line or polyline segment.
- Midpoint — snap to the midpoint of a segment.
- Nearest — snap to the closest point on a geometry.
- Grid — snap to a configurable grid spacing.
- Intersection — snap to the intersection of two line segments.
- Perpendicular — snap from a base point perpendicular to a line.
- Angle constraint — constrain the next point to specific angle increments from the previous point.
Active snap modes
Rather than always snapping to whatever is closest, you can manually select which intersection to use when multiple candidates are nearby, or average multiple points together to derive a snap location.
Configuring snapping
Snap settings (which modes are active, grid spacing, tolerance) are available from the snap configuration panel and persist with the document state.
Layers
Layer management
Create, rename, delete, and organize layers from the layer panel. Layers can be grouped for easier organization on larger projects.
Layer properties
Each layer has:
- Color
- Visibility
- Lock state — locked layers can’t be edited or selected
- Line type
- Label visibility — see Annotations & Labels
Phase-based filtering
Geometry is tagged with the survey phase it was created in (see Survey Phases), and the canvas filters what’s shown accordingly — you generally only see geometry relevant to the current phase, plus anything explicitly composed into a Drafting viewport.
Codelists
Codelists map survey codes to layers and layer properties (color, line type, line weight). During import, matching a geometry’s code against a codelist auto-creates and assigns the appropriate layer — useful for bringing in field data that uses a consistent coding scheme (e.g. from a data collector).
Annotations & Labels
Text annotations
Add positioned text labels with configurable style and anchoring.
Callout annotations
Text with a leader line and configurable arrowhead — useful for pointing at a specific feature from a label placed nearby.
Point labels
Point geometry can be automatically labeled based on its metadata (code, description, name), following cartographic best practices to avoid label overlap.
Label content
- Default format:
{code} {description} - Supported placeholders:
{code},{description},{name} - Empty labels (no content) are automatically hidden
Label placement
- Labels are positioned at the point location, with collision detection to prevent overlaps.
- Priority system: labels on the active layer are placed first.
- After priority sorting, remaining ties are broken top-to-bottom.
Zoom behavior
Labels maintain a constant screen size as you zoom, rather than scaling with world geometry:
- Target screen height: 12.0 px (configurable 8.0–24.0 px).
- World size is clamped between a minimum (default 0.5) and maximum (default 50.0) so labels stop shrinking/growing past those bounds at extreme zoom.
Performance
- Maximum label count (default 1000).
- Only labels within the visible camera bounds are generated (frustum culling).
- A minimum screen-height threshold prevents rendering labels too small to read.
Visibility controls
- Global toggle: Settings ▸ Labels ▸ Enable Point Labels.
- Per-layer toggle: the 🏷 button in the layer panel, next to the lock button. Labels also respect the layer’s own visibility.
Settings reference (Settings ▸ Labels)
| Setting | Range | Purpose |
|---|---|---|
| Enable Point Labels | on/off | Master toggle |
| Max Labels | 100–5000 | Cap on displayed labels |
| Target Screen Height | 8.0–24.0 px | Desired constant on-screen label height |
| Min World Size | 0.1–10.0 | Smallest label size when zoomed in |
| Max World Size | 10.0–200.0 | Largest label size when zoomed out |
| Min Screen Height | 4.0–20.0 px | Culling threshold for tiny labels |
| Label Format | template string | e.g. {code}: {description} |
Usage
- Create points with a code and description (e.g. via CSV import or manually).
- Enable labels: Settings ▸ Labels ▸ Enable Point Labels.
- Toggle per-layer visibility with the 🏷 button in the layer panel.
- Customize the label format string, e.g.
{code}: {description}.
Survey Phases
NullCad organizes work into four phases, each with independent camera state and status tracking. Geometry and operations are tagged with the phase they’re created in, so switching phases filters what’s shown and keeps work-in-progress in one phase from cluttering another.
| Phase | Purpose |
|---|---|
| Setup/Search | A virtual desk for laying out plans, research, and annotations before modeling begins. |
| Boundary Model | Building the surveyable model from historical plans. This is the default phase. |
| Reinstatement | Comparing field measurements against the boundary model and adjusting. |
| Drafting | Finalizing the plan for submission — page proxies, viewports, PDF export. |
Switching phases
Use the phase tabs at the top of the window. Each phase remembers its own camera pan/zoom position independently, so returning to a phase puts you back where you left off.
Why phase-based?
Cadastral survey work naturally moves through stages — research, modeling, field verification, and final drafting — each with different working material and different geometry. Tagging geometry by phase lets each stage stay visually uncluttered by the others, while still keeping everything in one document with a single operation history and undo/redo stack.
Phase status tracking
Each phase tracks its own completion status, useful for keeping a survey project’s overall progress visible at a glance.
Related tools
- Traverse Tool — used mainly in Boundary Model, to build the model from bearings and distances.
- Comparison Tool — used in Reinstatement, to compare field measurements against the model.
- Page Proxies & Viewports — used in Drafting, to lay out final sheets.
Traverse Tool
The Traverse tool builds connected survey traverses by entering bearings and distances, rather than clicking points on the canvas — the standard way boundary geometry is specified on a plan of survey.
Basic use
- Select the Traverse tool from the Survey tool category.
- Enter a starting point (or snap to an existing one).
- Enter a bearing and distance for each subsequent leg.
- Each leg is added to a connected polyline as you go.
Bearing format
Bearings are entered and displayed in degrees-minutes-seconds (DMS), rounded to the nearest second — the surveying standard — not decimal degrees.
The default input is HP notation (DDD.MMSS), where the digits after
the decimal point encode minutes and seconds rather than a decimal fraction
of a degree:
| Input | Parsed as |
|---|---|
123.2312 | 123°23’12“ |
45.30 | 45°30’00“ |
90 | 90°00’00“ |
45 30 15 | 45°30’15“ (space-separated DMS also accepted) |
To enter true decimal degrees instead, prefix the value with d (e.g.
d45.5 → 45.5° = 45°30’00“). Append * to reverse a bearing by 180°.
Scale factor support
The Traverse tool supports a Combined Scale Factor (CSF) for grid-to-ground distance conversion, so entered distances can be treated as either ground distances or grid distances depending on how your survey data was recorded. See Combined Scale Factor for the formula and when to apply it.
Where this fits in the workflow
The Traverse tool is most commonly used in the Boundary Model phase, building the surveyable model from bearings and distances taken off historical plans. See Survey Phases.
Comparison Tool
The Comparison tool compares field measurements against the boundary model, using bearing/distance input — the core of the Reinstatement phase (see Survey Phases).
Purpose
After building a boundary model from historical plans, field survey work verifies that model against measurements taken on the ground. The Comparison tool records those field bearing/distance observations against model points and stores the comparison results with the document.
Comparison storage
Comparisons are stored in the document (document/comparison.rs), so
comparison history is preserved with the project and available for review
or reporting alongside the rest of the survey data.
Bearing format
As with the Traverse tool, bearings are entered in DMS/HP
notation (DDD.MMSS), not decimal degrees. The comparison table displays
the survey bearing, measured bearing, and the delta between them each
rounded to the nearest arcsecond (e.g. 045° 30' 00") rather than showing
fractional seconds, keeping the table readable.
Related
- Traverse Tool — used to build the original boundary model.
- Combined Scale Factor — relevant when field measurements need grid/ground conversion before comparison.
Combined Scale Factor
The Combined Scale Factor (CSF) converts between grid coordinates (on the map projection) and ground/plane coordinates (real-world distances). NullCad uses the following formula for MGA (Map Grid of Australia) / UTM projections.
Formula
CSF = PSF × HSF
Where:
- CSF = Combined Scale Factor
- PSF = Point Scale Factor (accounts for map projection distortion)
- HSF = Height Scale Factor (accounts for elevation above the ellipsoid)
Point Scale Factor (PSF)
PSF = 0.9996 + 1.23 × (E - 500000)² × 10⁻¹⁴
Where:
- E = Easting coordinate (metres)
- 500000 = False easting (central meridian offset for UTM/MGA zones)
- 0.9996 = Central meridian scale factor (k₀) for UTM
PSF equals exactly 0.9996 at the central meridian (E = 500,000m) and increases as you move east or west.
Height Scale Factor (HSF)
HSF = 1 - (H × 0.1571 × 10⁻⁶)
Where:
- H = Ellipsoidal height (metres)
- 0.1571 × 10⁻⁶ ≈ 1 / 6,371,000 (approximation of 1/Earth radius)
HSF reduces distances as elevation increases, since higher points are further from Earth’s centre.
Implementation (pseudocode)
def get_csf(easting, northing, height):
# Point Scale Factor
psf = 0.9996 + 1.23 * (easting - 500000)**2 * 1e-14
# Height Scale Factor
hsf = 1 - (height * 0.1571e-6)
# Combined Scale Factor
return psf * hsf
Usage
| Direction | Operation |
|---|---|
| Grid → Ground | Divide by CSF (or multiply by 1/CSF) |
| Ground → Grid | Multiply by CSF |
Worked example
For a point at E 456,789.000, N 6,123,456.000, H 150.000:
PSF = 0.9996 + 1.23 × (456789 - 500000)² × 10⁻¹⁴
= 0.9996 + 1.23 × 1,867,422,121 × 10⁻¹⁴
= 0.9996 + 0.00002297
= 0.99982297
HSF = 1 - (150 × 0.1571 × 10⁻⁶)
= 1 - 0.00002357
= 0.99997643
CSF = 0.99982297 × 0.99997643
= 0.99979941
A 100m ground distance would be 99.9799m on the grid.
Where to configure it
The active CSF is set in the survey settings and applied by the Traverse tool when converting between grid and ground distances.
Caveat
This formula is a simplified approximation suitable for most surveying work within a single UTM/MGA zone. For high-precision geodetic work spanning zone boundaries or requiring rigorous error bounds, use the full Transverse Mercator equations instead.
TIN Surfaces
NullCad can build Triangulated Irregular Network (TIN) surfaces from selected points, using a Constrained Delaunay Triangulation.
Creating a surface
- Select the points you want to triangulate.
- Activate the TIN Surface tool (Surface & Drafting category).
- Optionally add hard or soft breaklines to constrain the triangulation along known features (e.g. a ridge line or a drainage line).
Non-destructive
Building a TIN surface never modifies the source points — the surface is a
separate geometry object (TinSurface) that references the input data, so
you can regenerate or delete the surface without losing the underlying
point data.
Breaklines
- Hard breaklines force triangle edges to follow the breakline exactly — used for discontinuities like retaining walls or building edges.
- Soft breaklines influence triangulation without forcing a hard edge — used for smoother features like ridge or valley lines where a small amount of triangle flexibility is acceptable.
Page Proxies & Viewports
The Drafting phase (see Survey Phases) is where a survey project becomes a finished plan for lodgement. Two geometry types drive this: page proxies and viewports.
Page proxies
A page proxy lays out a drafting sheet at a chosen draw scale, on one of the standard paper sizes:
- ISO A-series (A0–A4)
- US (Letter, Legal, Tabloid, …)
- ANSI (A–E)
- ARCH (A–E)
Each page proxy becomes one page in the exported PDF (see PDF Export). A page can be assigned a sheet template (a title block) — see below.
Sheet templates
A sheet template supplies the title-block linework and text placed on top
of a page’s content, with substitutable fields like <PROJECT>, sheet
number, and sheet name.
Sheet templates can be imported directly from a DXF file’s paper-space
layout: NullCad recovers the layout’s true physical paper size from the
DXF’s embedded plot-settings (not just its arbitrary limits/extents), and
rescales non-millimeter content to fit. INSERT block references (common
for real title blocks) are expanded recursively.
Assigning a template to a sheet resizes the page to the smallest standard paper size that fully contains the template, so the template’s linework — laid out in its own physical millimeter coordinates — always ends up inside the visible page rather than centered against a mismatched size.
The Sheet Manager (accessible from the Drafting phase) lists all sheets in the document, lets you assign templates, fill in title-block field values, and reorder/renumber sheets.
Viewports
A viewport is a window placed on a Drafting-phase page that displays content from another phase (most commonly Boundary Model), with its own independent pan, zoom, and rotation — similar to a viewport in traditional CAD software. This is how survey/model geometry actually ends up on a printed sheet: you draft the page and title block in the Drafting phase, then open one or more viewports onto the phase that holds the real geometry.
Draw scale
Each page proxy has its own draw scale, so different sheets in the same document can be laid out at different scales (e.g. an overview sheet at 1:1000 and a detail sheet at 1:200). Linetype patterns (dashes, dots) on the Drafting-phase canvas follow whichever page’s draw scale the camera is currently nearest to, so on-screen dash spacing matches what will actually print on that sheet.
PDF Export
NullCad exports drafting sheets as a multi-page vector PDF — geometry is written as real vector paths (and text as glyph outlines), not a raster image, so the output stays crisp at any zoom and prints cleanly.
What gets exported
Each page proxy in the document becomes one PDF page:
- Page content — geometry sharing the page’s phase and intersecting its bounds is transformed from world meters to paper millimeters at the page’s draw scale, styled per layer (color, line weight, line type), and clipped to the sheet.
- Viewport content — geometry composed through any viewports on the page, from whichever phase each viewport targets, at that viewport’s own pan/zoom/rotation.
- Sheet template — the assigned title block, rendered on top, with its
substitutable fields (
<PROJECT>, sheet number/name, etc.) filled in.
Pages are ordered by (sheet number, name), so legacy documents (where
every sheet number is 0) keep their existing name order.
Exporting
Open the PDF export dialog from the Sheet Manager or File ▸ Export ▸ PDF. You can choose which pages to include and export options such as monochrome output.
Line types in the exported PDF
Custom linetype patterns (dashes, dots, center lines, etc.) are resolved against the project’s linetype library and interpreted as paper millimeters, independent of the page’s draw scale — so a dash pattern defined for a 1:500 sheet looks the same physical size as the same pattern on a 1:100 sheet.
Text
Text is rendered as filled glyph outlines rather than PDF text objects, so the exported PDF doesn’t depend on font availability on the viewer’s system and renders identically everywhere.
Supported Formats
Native (pure Rust — always available)
| Format | Extension | Notes |
|---|---|---|
| ESRI Shapefile | .shp | Industry-standard GIS vector format |
| GeoJSON | .geojson, .json | Web-friendly geographic data format; non-finite coordinates are rejected on import |
| DXF | .dxf | AutoCAD Drawing Exchange Format, with native Circle/Arc support; non-finite coordinates are rejected on import |
| CSV | .csv | Point data with configurable column mapping (import) and column/format selection (export) — see CSV Export |
GDAL (optional — requires the gdal feature)
| Format | Extension | Notes |
|---|---|---|
| KML | .kml | Keyhole Markup Language, for Google Earth |
| GeoPackage | .gpkg | Modern SQLite-based GIS format |
| MapInfo TAB | .tab | MapInfo native format |
| OpenFileGDB | .gdb | ESRI File Geodatabase |
See Building from Source for how to build with GDAL support.
Coordinate reference systems
All formats support CRS handling with automatic transformation options via proj4rs.
DXF import notes
- BYBLOCK colors fall back to black (the source block’s actual color isn’t always recoverable through the parsing crate used).
- When importing DXF sheet templates (paper-space layouts, for use as drafting title blocks), the true physical paper size is recovered from the DXF’s plot-settings where possible; binary DXF files fall back to a layout-limits/content-extents heuristic.
Web viewer bundle (.ncweb)
A separate, purpose-built export format for the companion field/phone viewer — see Web Viewer Bundle.
CSV Export
NullCad supports exporting geometry to CSV with customizable column options, complementing CSV import.
Column selection
| Column | Description |
|---|---|
| Name | Point number or label (auto-generated as PT1, PT2, … if empty) |
| Vertex Index | Index of a vertex within a multi-vertex geometry (lines/polylines) |
| X | Easting or longitude |
| Y | Northing or latitude |
| Z / Height | Elevation or height (defaults to 0 if unset) |
| Code | Feature code for categorization |
| Description | Detailed description or notes |
| Layer | Layer name |
Format options
- Coordinate precision: 0–10 decimal places, defaults to 6.
Geometry handling
By default, only Point geometry is exported.
Enabling export non-point geometries adds:
- Lines — all vertices (start and end), one row each.
- Polylines — all vertices, one row each.
- Polygons — the centroid, as a single row.
- Circles, Arcs, Rectangles — the center point, as a single row.
- Text — the insertion point, as a single row.
Export behavior
- Selection-aware: if geometry is selected, only the selection is exported; otherwise all active (non-deleted) geometry is exported.
- Auto-generated names: points with an empty name get
PT1,PT2, etc. - Default height: Z/Height defaults to
0(rather than being left empty) when unset. - Multiple rows per geometry: exporting a line/polyline with export of non-points enabled produces one row per vertex, all sharing the same metadata (name, code, description, …).
Usage
- Select geometry to export (optional — leave nothing selected to export everything).
- File ▸ Export ▸ CSV…
- Choose which columns to include, and optionally enable “Export non-point geometries.”
- Export, then choose the output file location.
Default configuration
Enabled by default: Name, X, Y, Z/Height, Code, Description, 6 decimal places of precision.
Disabled by default: Vertex Index, Layer, non-point geometry export.
CSV format
Standard RFC 4180: header row, comma-separated fields, fields containing commas are quoted, empty fields are unquoted empty strings.
Example — points only (default)
name,x,y,z,code,description
PT1,500000.123456,4500000.654321,123.450000,CTRL,Control Point
PT2,500100.234567,4500100.765432,124.560000,BNDRY,Boundary Marker
PT3,500200.345678,4500200.876543,0.000000,TOPO,Topographic Point
Example — line vertices with vertex index
name,vertex_index,x,y,code
LINE1,0,100.000000,200.000000,BOUND
LINE1,1,150.000000,250.000000,BOUND
LINE1,2,200.000000,300.000000,BOUND
Example — auto-generated names
name,x,y,z
PT1,100.000,200.000,0.000
PT2,300.000,400.000,0.000
See also
- Supported Formats — CSV import and other export formats.
Web Viewer Bundle
NullCad can export a self-contained .ncweb bundle for a companion
browser/phone viewer — useful for reviewing a survey project in the field
without running the full desktop application.
What’s in the bundle
.ncweb is a ZIP archive containing:
manifest.json— CRS, extent, camera position, layer list.features.geojson— geometry as GeoJSON, with styles resolved (colors, line weights, etc.) at export time so the viewer doesn’t need to replicate NullCad’s full styling logic.resources/— embedded resources (e.g. reference images) needed by the viewer.
Exporting
File ▸ Export ▸ Web Viewer Bundle…, then choose an output location for
the .ncweb file.
The viewer
The companion viewer is a TypeScript Progressive Web App (PWA), designed
for field/phone use — it opens a .ncweb bundle and renders the exported
geometry with the resolved styles, without needing network connectivity
once loaded.
Keeping exporter and viewer in sync
The .ncweb format is produced by the Rust exporter
(src/gis_io/native/web_bundle.rs) and consumed by the separate viewer/
TypeScript project. Because the format is shared between two independently
built codebases, changes to the bundle’s schema need to land on both sides
together.
Lua Plugins
NullCad can be extended with Lua 5.4 plugins. Plugins can add menu commands, interactive canvas tools, panels and dialogs, and automation hooks that react to application events. Every document mutation a script makes goes through the normal operations system, so plugin edits are undoable and appear in the audit trail like hand-drawn geometry.
Installing plugins
Plugins live in the NullCad config directory:
| Platform | Location |
|---|---|
| Linux | ~/.config/nullcad/plugins/ |
| Windows | %APPDATA%\nullcad\plugins\ |
| macOS | ~/Library/Application Support/nullcad/plugins/ |
Two layouts are supported:
- Single file — drop
my_plugin.luadirectly inplugins/. - Directory — a folder containing
plugin.tomland an entry script:
# plugins/survey-helpers/plugin.toml
name = "Survey Helpers"
id = "survey-helpers" # optional, defaults to the folder name
version = "0.1.0"
description = "Point grids, selection reports"
author = "You"
entry = "init.lua" # optional, defaults to init.lua
Plugins load on startup. Use Plugins ▸ Reload Plugins after editing a
script, and Plugins ▸ Plugin Manager… to enable/disable plugins or see
load errors. Plugins ▸ Run Script… executes a one-off .lua file
without installing it (useful for batch automation).
A minimal plugin
-- plugins/hello.lua
nc.register_command{
id = "hello",
title = "Say Hello",
run = function(ctx)
ctx:toast("Hello from Lua!", "success")
end,
}
This adds Plugins ▸ hello ▸ Say Hello to the menu bar.
The nc global
Every plugin runs in its own Lua state with the nc table installed:
| Item | Purpose |
|---|---|
nc.version | NullCad version string |
nc.ctx | Application context (see below) |
nc.register_command{...} | Add a menu command |
nc.register_tool{...} | Add an interactive canvas tool |
nc.register_panel{...} | Add a toggleable panel |
nc.on(event, handler) | Subscribe to an application event |
All callbacks receive ctx (the same object as nc.ctx) as their first
argument.
Commands
nc.register_command{
id = "count", -- required, unique within the plugin
title = "Count Geometry", -- menu label (defaults to id)
run = function(ctx) ... end, -- required
}
Tools
Tools receive snapped canvas clicks in world coordinates. Return true from
on_click to finish the tool (the app returns to selection mode); return
false/nothing to keep receiving clicks. Tool state is just Lua upvalues.
local first = nil
nc.register_tool{
id = "two-point",
name = "Two Point Line",
instructions = "Click start point, then end point",
on_click = function(ctx, x, y)
if not first then
first = {x = x, y = y}
return false
end
ctx:create_line{start_x = first.x, start_y = first.y, end_x = x, end_y = y}
first = nil
return true
end,
}
Registered tools appear in the plugin’s menu; activating one behaves like any built-in tool (Esc cancels, snapping applies).
Panels and dialogs
UI is declarative: a list of widget tables rendered by the app. Button
clicks call your on_event handler with the button id and the current
values of all input widgets (keyed by widget id).
Widget types:
{type = "heading", text = "Section"}
{type = "label", text = "Static text"}
{type = "separator"}
{type = "text", id = "name", label = "Name", value = "default"}
{type = "number", id = "dist", label = "Distance", value = 10.0}
{type = "checkbox", id = "flag", label = "Enabled", value = true}
{type = "select", id = "mode", label = "Mode", options = {"A", "B"}, value = "A"}
{type = "button", id = "go", label = "Go"}
Panels are registered once and toggled from the plugin’s menu (or with
ctx:open_panel(id) / ctx:close_panel(id)); update their contents with
ctx:update_panel(id, widgets).
nc.register_panel{
id = "quick-point",
title = "Quick Point",
widgets = {
{type = "number", id = "x", label = "Easting", value = 0.0},
{type = "number", id = "y", label = "Northing", value = 0.0},
{type = "button", id = "create", label = "Create Point"},
},
on_event = function(ctx, event, values)
if event == "create" then
ctx:create_point{x = values.x, y = values.y}
end
end,
}
Dialogs are opened from any callback with ctx:show_dialog. Return
true from the handler to close the dialog; the window’s ✕ also closes it.
ctx:show_dialog{
title = "Grid Options",
widgets = {
{type = "number", id = "rows", label = "Rows", value = 5},
{type = "number", id = "spacing", label = "Spacing", value = 10.0},
{type = "button", id = "ok", label = "Create"},
{type = "button", id = "cancel", label = "Cancel"},
},
on_event = function(ctx, event, values)
if event == "ok" then make_grid(ctx, values) end
return true -- close on any button
end,
}
Events (automation)
nc.on("document_saved", function(ctx, e)
ctx:log("Saved to " .. e.path)
end)
| Event | Payload |
|---|---|
document_opened | {path} |
document_saved | {path} |
document_new | {} |
operation_applied | {description} |
selection_changed | {ids, count} |
phase_changed | {phase} |
Events are delivered once per frame, after the mutation completes.
Operations performed inside an operation_applied handler do not
re-trigger the event (no feedback loops).
ctx reference
Coordinates are world meters (f64). Geometry and layer ids are UUID strings.
Errors raise Lua errors — wrap calls in pcall if you want to recover.
Reading
| Method | Returns |
|---|---|
ctx:document_info() | {project_name, file_path, geometry_count, layer_count, current_phase, active_layer_id, ...} |
ctx:current_phase() | "SetupSearch" | "BoundaryModel" | "Reinstatement" | "Drafting" |
ctx:list_layers() | {layers = {{id, name, visible, locked, color, order}, ...}} |
ctx:list_geometry{layer_name=, geometry_type=, limit=} | {count, items = {{id, type, name, layer_name, z, summary}, ...}} (all filters optional) |
ctx:get_geometry(id) | Full record incl. geometry table (e.g. {type = "Point", x, y}; Text/Rectangle include rotation_degrees, CCW from east) |
ctx:selection() | Array of selected geometry ids |
Creating geometry
All creators accept optional layer_id/layer_name (defaults to the
default layer), name, description, and (where meaningful) z. They
return the new geometry’s id.
ctx:create_point{x=, y=, z=}
ctx:create_line{start_x=, start_y=, end_x=, end_y=}
ctx:create_polyline{points = {{x=, y=}, ...}} -- ≥ 2 points
ctx:create_polygon{points = {{x=, y=}, ...}} -- ≥ 3 points
ctx:create_circle{center_x=, center_y=, radius=}
ctx:create_arc{center_x=, center_y=, radius=, start_angle=, end_angle=}
ctx:create_rectangle{corner1_x=, corner1_y=, corner2_x=, corner2_y=}
ctx:create_text{x=, y=, content=, height=}
ctx:create_layer{name=, color="#RRGGBB"} -- returns layer id
Modifying
| Method | Effect |
|---|---|
ctx:delete(id) | Soft-delete a geometry object (undoable) |
ctx:move_geometry(ids, dx, dy) | Translate the given ids by a delta |
ctx:rotate_geometry(ids, center_x, center_y, angle_degrees) | Rotate around a center point, clockwise degrees (surveying convention, same as the Rotate tool) |
ctx:set_selection(ids) / ctx:clear_selection() | Change the selection |
Feedback & UI
| Method | Effect |
|---|---|
ctx:toast(message, level?) | Toast notification ("info", "success", "warning", "error") |
ctx:log(message, level?) | Write to the console (F12), attributed to the plugin |
ctx:show_dialog{title=, widgets=, on_event=} | Open a dialog |
ctx:update_panel(id, widgets) | Replace a panel’s widgets (values with matching ids survive) |
ctx:open_panel(id) / ctx:close_panel(id) | Show/hide a registered panel |
Notes & limitations
- Scripts run on the UI thread: a long loop blocks the interface. Keep per-callback work modest.
ctxis only valid while NullCad is calling into your script (load, callbacks). Don’t stash it for use from Lua coroutines/timers — there are none anyway.- Lua’s standard library is available (
math,string,table,io,os), so scripts can read/write files for import/export automation. - Full examples live in
docs/examples/plugins/in the repository:survey-helpers/(commands, tools, panels, events) andalign-text.lua(rotate a text object to match a clicked line direction).
MCP Server
NullCad includes an in-process Model Context Protocol (MCP) server (HTTP+SSE), letting an AI agent read and create geometry in the currently open document.
How it works
The MCP server runs as a background tokio task inside the application. Mutations requested by an agent are funnelled back to the egui main thread via a channel, so they go through the same operation system as any other edit — every agent-driven change is undoable and recorded in the audit trail, exactly like Lua plugin edits or manual drawing.
Enabling the server
The MCP server’s settings (enable/disable, port) are available in the application settings and persist with the document.
Connecting an agent
Once enabled, point an MCP-compatible client at the server’s HTTP+SSE endpoint to give an agent access to:
- Reading document info, layers, and geometry.
- Creating and modifying geometry (points, lines, polylines, polygons, circles, arcs, rectangles, text).
- Reading and changing the current selection.
The tool surface mirrors the Lua plugin API’s ctx methods (see
Lua Plugins) — both are implemented against the
same underlying operation builders (mcp/tools.rs), so behavior (units,
coordinate conventions, rotation direction) is consistent between the two
integration paths.
Why in-process?
Running the MCP server inside the application (rather than as a separate process talking to a file) means an agent is always working against the live, in-memory document state — including unsaved changes — and its edits appear immediately in the UI, just like a human’s.
The .nullcad Archive
NullCad projects are saved as a ZIP-based archive format (.nullcad),
containing:
manifest.json — project metadata, resource index, phase status
document/ — geometry, layers, document metadata, comparisons,
selection sets
operations/ — complete operation history
state/ — camera positions, snap config, application settings
resources/ — embedded files (PDFs, images, field data)
Why an archive, not a single JSON file?
Bundling resources (reference PDFs, images, field data files) directly in
the project file means a .nullcad file is fully self-contained — moving,
copying, or archiving one file preserves everything needed to reopen the
project exactly as it was, without broken links to external files.
Legacy JSON format
Older NullCad projects were saved as plain JSON. These are automatically detected and migrated on load — you don’t need to convert them manually.
Operation history as audit trail
The operations/ directory holds the complete, append-only operation log
for the document (see Operation System) —
every mutation ever made, in order, including the ones later undone. This
gives every .nullcad file a full audit trail of how the survey model was
built, not just its final state.
Resource management
Plans, photos, and field data files can be embedded directly in the
project archive under resources/ and referenced from geometry or
annotations — e.g. a PlanImage geometry object placing a reference PDF page
in world space, or a resource attached to a point for field documentation.
Related formats
.ncweb— a separate, purpose-built bundle for the companion web viewer. See Web Viewer Bundle.
Precision Handling
Cadastral surveying works in large coordinate systems — a typical UTM
Easting/Northing looks like 500,000m / 5,000,000m. Rendering that
directly on the GPU is a precision problem: a 32-bit float (f32) at
1,000,000m has roughly 0.125m of representable precision, which is
useless for millimeter-accuracy survey work.
The solution: camera-relative rendering in f64
-
All geometry is stored in f64 world coordinates.
-
Camera position is tracked in f64 — handles coordinates like 500,000m without loss.
-
Before rendering, coordinates are transformed to camera-relative space, in f64:
#![allow(unused)] fn main() { vertex_camera = vertex_world - camera_position } -
Only camera-relative coordinates are converted to f32, right before upload to the GPU vertex buffer.
Because camera-relative coordinates are small (bounded by whatever’s actually visible on screen), f32 precision is excellent for them — even though the original world coordinates were far too large for f32 to represent precisely.
Worked example
A point at (500000.001, 5000000.001) with the camera at
(500000, 5000000) becomes (0.001, 0.001) in camera-relative space —
small enough for f32 to represent exactly.
Rule of thumb
Never apply the camera position transform in a shader. Do the world-to-camera-relative subtraction in f64 on the CPU first, then hand the already-small f32 values to the GPU. Applying it in the shader (in f32) reintroduces the precision loss the whole scheme exists to avoid.
Where this matters in the codebase
renderer/mod.rs— the wgpu pipeline that performs the camera-relative transform.renderer/camera.rs—Camera2D, tracked in f64.- Every geometry type in
geometry/stores its coordinates as f64.
Operation System
Every mutation to a NullCad document — drawing, moving, deleting, vertex edits, layer changes, comparisons, and more — goes through a single, strongly-typed operation system. This is what powers undo/redo, the audit trail, and (indirectly) the Lua plugin and MCP server integrations, since both reuse the same operation builders rather than mutating the document directly.
Key pieces
OperationType— a strongly-typed enum covering every mutation: create/delete/restore, move/rotate/scale, vertex edits, split/extend/trim, explode, layer changes, comparisons, and more.OperationHistory— an append-only log. Undo doesn’t remove the original operation — it appends a compensating (inverse) operation. Redo re-applies. This means the full history is always preserved as an audit trail, and a new operation clears the redo stack (no branching history).DocumentState— combines aDocument, anOperationHistory, and aVec<Snapshot>.apply_operation()applies the operation, records it, and auto-snapshots periodically (every 50 operations by default).undo()/redo()create and apply the compensating operations.- Phase tracking — every operation is tagged with the active survey phase at the time it was applied.
Example
#![allow(unused)]
fn main() {
let op = OperationType::MoveGeometry {
geometry_ids: vec![id],
delta: Vector2::new(10.0, 20.0),
};
doc_state.apply_operation(user_id, op)?;
doc_state.undo(user_id)?; // applies the compensating op
doc_state.redo(user_id)?;
}
Soft deletes
Deleted geometry is tombstoned rather than physically removed, preserving history — a delete is just another operation with a well-defined inverse (restore).
Persistence
Operation history is saved with the document (see The .nullcad Archive), so the full edit history travels with the project file.
Collaboration-ready by design
The operations system was designed to support future collaborative
editing with minimal changes: UUID geometry IDs, tombstone soft deletes,
operation metadata (user_id/timestamp/phase/description), an append-only
log, and a HashMap-based document are all CRDT-friendly properties. The
remaining path to multi-user editing is real authenticated user IDs, an
operation-sync transport, conflict resolution, and per-user undo — without
needing to break the existing model.
Adding a new operation
- Add a variant to
OperationTypeinsrc/operations/types.rs. - Implement
inverse(),summary(), andaffected_geometry_ids()for it. - Add execution logic in
apply_operation_type()insrc/operations/executor.rs. - Apply it via
DocumentState::apply_operation(). - Test the operation, its inverse, and its summary.
Module Structure
The application state lives on NullCadApp, split across several app_*
files by concern (canvas, dialogs, menus, tools, properties, layers, status
bar, input, I/O), backed by a set of focused subsystem modules.
| Module | Purpose |
|---|---|
app.rs | Main application state and egui integration |
app_canvas_ui.rs, app_dialogs_ui.rs, app_menu_ui.rs, app_tool_ui.rs, app_properties_ui.rs, app_layer_ui.rs, app_statusbar_ui.rs, app_input.rs, app_io.rs | UI and input, split by concern |
document/ | Document model, geometry objects, spatial index, comparisons, selection sets |
operations/ | Operation types, history, executor, snapshots — see Operation System |
commands/ | Legacy command system (superseded by operations/, still present, being migrated out) |
geometry/ | Geometry primitives (Point, Line, Polyline, Polygon, Rectangle, Circle, Arc, Text, PlanImage, TIN surface, page proxy, viewport) |
renderer/ | wgpu renderer, camera, auto-contrast, line types, PDF/image underlays, tile rendering — see Precision Handling |
tools/ | Interactive tool modules (point, edit, trim, offset, text/callout annotation, TIN, page proxy, viewport) |
annotations/ | Text and callout annotations with anchoring, styling, and leaders |
labels/ | Point label generation with collision detection |
selection/, select_similar.rs | Selection set management, hit testing, select-by-attribute |
snapping/ | Snap point detection and configuration |
layers/, codelist/ | Layer/group management and code→layer import mapping |
gis_io/native/ | Pure-Rust import/export: Shapefile, GeoJSON, DXF, CSV, .ncweb web bundle |
gis_io/gdal/ | Optional GDAL formats: KML, GeoPackage, MapInfo TAB, FileGDB |
pdf_export/, pdf_viewer.rs | Vector PDF export (page proxies → pages) and in-app PDF plan viewer |
mcp/ | In-process MCP server exposing the document to AI agents |
plugins/ | Lua plugin system: commands, tools, panels/dialogs, event hooks |
console/ | Application message log |
settings/, autosave.rs, template.rs | Persistent settings, autosave/crash recovery, document templates |
ui/ | Reusable UI components (operation log, phase tabs, resource panel, color/codelist pickers) |
archive/ | ZIP-based .nullcad file reading/writing with resource management |
utils/ | DMS parsing, unit conversions |
Document model (src/document/)
Document— the central store; geometry in aHashMapkeyed byGeometryId.GeometryId— UUID v4, for global uniqueness (collaboration-ready).GeometryObject— wraps aGeometrywith metadata (created_by, timestamps, phase, layer, name/code, tombstone).- Spatial index — an
rstarR-tree, auto-maintained on create/delete/modify and rebuilt on load; powers O(log n) hit testing and range queries.
Geometry (src/geometry/)
The Geometry enum covers Point, Line, Polyline, Polygon, Text,
Circle, Arc, Rectangle, PlanImage (a PDF page placed in world
space), TinSurface, PageProxy, Viewport, and ViewportView. All
coordinates are f64; the crate integrates with the geo crate’s types.
Modular tool development
Interactive tools are self-contained modules in src/tools/, each
encapsulating its own state and behavior, which keeps the app_* files
thin and lets tools be unit-tested in isolation.
A tool module typically provides:
- A state struct (
new(),reset()). - A tool struct holding that state, with
name(),instructions(),handle_click(...) -> (Option<OperationType>, bool)(the bool = should-exit),render_ui(...), andreset().
To add a new tool:
- Add
pub mod my_tool;insrc/tools/mod.rsand a variant to theToolenum (plus itsname()/instructions()arms). - Add a tool instance field to
NullCadAppand initialize it. - Dispatch clicks and UI rendering from the
app_*files based on the active tool. - Add a toolbar button that sets the active tool and calls
reset().
src/tools/point_tool.rs is the reference implementation. Best practices:
keep all tool logic in the module, route every mutation through
DocumentState::apply_operation(), reset tool state on activation, provide
clear instructions() text, handle edge cases (invalid input, no snap
point), and unit-test the module.
Adding a new geometry type
- Add the type in
src/geometry/using f64 coordinates. - Add a variant to the
Geometryenum ingeometry/mod.rs. - Handle it in the renderer’s vertex generation.
- Handle it everywhere else the
Geometryenum is matched (hit testing, serialization, export paths, MCPtools.rs, etc.) — the compiler will point these out.
Camera controls (renderer/camera.rs)
- Pan: middle mouse drag (
PAN_SENSITIVITY, default 0.005), zoom-scaled. - Zoom: scroll wheel toward cursor; clamp range ~0.001–10000.
- Position lives in f64; the view-projection matrix handles only zoom/aspect, never translation (see Precision Handling).
Keyboard Shortcuts & Controls
A full, searchable list is available in-app via the keyboard shortcuts help dialog. This page summarizes the defaults.
Navigation
| Action | Input |
|---|---|
| Pan | Middle mouse drag |
| Zoom | Scroll wheel (zooms toward cursor) |
| Reset camera | Reset Camera button |
| Distraction-free mode | F11 |
| View bookmarks | 1–9 to recall, Ctrl+1–9 to set |
Selection
| Action | Input |
|---|---|
| Select | Click on geometry |
| Multi-select | Ctrl+Click |
| Rectangle select | Click and drag on empty canvas |
| Select all | Ctrl+A |
Editing
| Action | Input |
|---|---|
| Undo | Ctrl+Z |
| Redo | Ctrl+Y or Ctrl+Shift+Z |
| Delete selected | Delete |
| Explode | B |
| Transform gizmo (move/rotate selection) | W |
| Toggle console | F12 |
Tool categories
| Category | Tools |
|---|---|
| Select | Default selection mode |
| Draw | Point, Line, Polyline, Leader, Polygon, Text, Callout, Circle, Arc, Rectangle |
| Modify | Move, Rotate, Scale, Trim/Extend, Offset, Transform Gizmo |
| Survey | Traverse, Comparison |
| Measure | Distance and bearing measurement (bearing shown in DMS, rounded to the nearest second) |
| Surface & Drafting | TIN Surface, Page Proxy, Viewport |
See also: User Guide for what each tool does.